Mini Vibe: a coding agent in your terminal and your browser
Mini Vibe is a coding agent in about 350 lines of Python, after Mistral Vibe in the terminal and Vibe Code in
the browser. Run it in your repository and type a task: the agent reads the code, edits files and
runs commands. Edits apply at once, except to git's settings, and git diff shows them; any
command outside a short read-only list asks first. The same session is open in a browser tab, which shows the work as it streams and
answers approvals too. While the agent works, type a line to steer it, or press Ctrl-C to stop it.
The recording: Mini Vibe fixes more-itertools issue #1336,
where replace() yields None for an empty iterable when window_size > 1. The browser tab answers
the second command, a line typed mid-run steers the agent to the fix locate() already uses, and a
allows every later command.
Demonstrates: a permission policy that runs read-only commands and asks before any other, a host sandbox in your checkout, two views following one session's events, answering an approval from either view, and steering the running turn.
Run, from the repository you want to work on, with MISTRAL_API_KEY set and MINI_VIBE naming
the example's folder: ALLOW_DANGEROUS_LOCAL_HOST=1 uv run --project "$MINI_VIBE" python "$MINI_VIBE/mini_vibe.py"
Allow edits, ask before commands
The agent works in your checkout through DangerousLocalSandbox(workspace=os.getcwd()): its shell
and file tools run on your machine, with no container. The policy decides which calls wait for you:
# The patterns read the call's arguments as JSON text, where a newline is the two characters \n.
# A command that could run a second command or write a file asks: a separator, a newline, a
# redirection, an expansion ($), a backtick, or git's --output option.
ASK = ("*;*", "*&*", "*|*", "*\\n*", "*>*", "*<(*", "*$*", "*`*", "*--output*")
# Git's settings can name a program that `git diff` or `git log` runs, so an edit to them asks: a
# path with .git in it (.git/, .gitattributes, .gitconfig), in any case, since macOS disks ignore
# case. `path` matches both write_file's `path` and edit's `file_path`.
GIT_SETTINGS = ('*path": "*[.][gG][iI][tT]*',)
READ_ONLY = (
"ls",
"ls *",
"cat *",
"head *",
"tail *",
"wc *",
"grep *",
"git status",
"git status *",
"git diff",
"git diff *", # with the space: `git difftool` runs a program of its choosing
"git log",
"git log *",
"git show",
"git show *",
)
coder = agents.Agent(
model="mistral-large-4",
name="mini-vibe",
instructions=(
"You are a coding agent working in the user's repository, your current directory. Read the "
"code before you change it, make the smallest change that does the task, then run the "
"project's tests. End with two or three lines: what you changed and what the tests said."
),
environment=agents.DangerousLocalSandbox(workspace=os.getcwd()), # your checkout, no container
permission_policy=agents.PermissionPolicy(
default="ask",
rules=[
agents.PermissionRule(tools=["file_system.read_file"], decision="allow"),
agents.PermissionRule(
tools=["file_system.write_file", "file_system.edit"],
decision="allow", # like Vibe: edits apply, `git diff` shows them
sensitive=GIT_SETTINGS,
),
agents.PermissionRule(
tools=["file_system.bash"],
decision="ask",
sensitive=ASK,
allowlist=[f'*"command": "{command}"*' for command in READ_ONLY],
),
],
),
)- Reading and editing files runs. You review the result with
git diff, as in Vibe. - An edit to git's settings asks.
.git/configcan name a program that a plaingit diffruns, and.gitattributespicks the files it runs on. The pattern matches.gitin any case: macOS disks ignore case, so.GIT/configis the same file. The pattern also matches.github/,.gitignore, and an edit whose text has.gitin it: these ask too. - A read-only command runs.
grep -n window more.pymatches the allowlist. - Any other command asks.
python -m unittest,find . -deleteandgit difftoolmatch no allowlist pattern:git diff *has a space afterdiff. - A command that could run a second command or write a file asks, even when it starts like a
read-only one. The patterns match the call's arguments as JSON text, where a newline is the two
characters
\n:cat a.py; rm -rf .andcat a.pyfollowed by a new line andrm -rf .both match asensitivepattern, andsensitiveis checked before the allowlist.
The patterns read the command's text: they catch a careless command, not a hostile one. An edit
to git's user settings outside the repository, ~/.config/git/config, runs unasked. Any other edit
runs unasked too, so before you allow a command, look at what the turn changed: an edited test file
changes what python -m unittest runs. test_mini_vibe.py runs commands and edits like these,
one per case, through the policy with a scripted model; from the example's folder, run it with
ALLOW_DANGEROUS_LOCAL_HOST=1 uv run pytest -o asyncio_mode=auto test_mini_vibe.py.
The program needs ALLOW_DANGEROUS_LOCAL_HOST=1, and building the sandbox prints a UserWarning:
the agent's commands run as you, with your files and your network. Run it in a checkout you can
reset.
One session, two views
The terminal and the browser tab each read session.iter_events() on their own. Neither drives the
turn: whoever sends a message starts it, and both views see every event. The terminal prints each
event as it comes:
async def show(self) -> None:
async for event in self.session.iter_events():
match event:
case agents.TurnStarted():
self.turn_id = event.turn_id
case agents.TextDelta(entry_id=entry_id, text=text):
self.streamed.add(entry_id)
self.out(text, end="")
case agents.EntryCompleted(entry=agents.MessageEntry(role="assistant") as message):
self.out("" if message.id in self.streamed else message.text)
case agents.EntryCompleted(entry=agents.ToolCallEntry(name=name, arguments=args)):
self.out(f"{ORANGE}⏺{RESET} {BOLD}{name.split('.')[-1]}{RESET} {web.summary(args)}")
case agents.EntryCompleted(entry=agents.ToolResultEntry() as result):
lines = web.result_text(result).strip().splitlines() or [""]
more = f" (+{len(lines) - 1} lines)" if len(lines) > 1 else ""
self.out(f" {DIM}⎿ {lines[0][:100]}{more}{RESET}")
case agents.CallbackOpened(callback=agents.OpenApproval() as approval):
await self.ask(approval)
case agents.CallbackResolved(callback_id=callback_id):
for approval in [a for a in self.waiting if a.id == callback_id]:
self.waiting.remove(approval)
self.out(f" {DIM}answered in the web view: {web.summary(approval.request.input)}{RESET}")
case agents.TurnEnded(state=state):
self.turn_id = None
if not isinstance(state, agents.Completed):
self.out(f"{DIM}[{type(state).__name__}]{RESET}")
self.out(f"{BOLD}>{RESET} ", end="")
if self.waiting and not self.prompting and not isinstance(event, agents.TextDelta):
self.prompt() # ask again, below the new outputThe web view sends the same events to the page as server-sent events, each with its id. A tab that
reloads sends back the last id it got, and the stream goes on from there, with nothing shown twice:
async def events(request: Request) -> StreamingResponse:
from_the_page(request)
# A browser that reconnects sends the id of the last event it got: nothing shows twice.
after = request.headers.get("last-event-id")
async def stream():
async for event in session.iter_events(after=after):
if (data := as_json(event)) is not None:
yield f"id: {event.id}\ndata: {json.dumps(data)}\n\n"
return StreamingResponse(stream(), media_type="text/event-stream")Answer from either view
An approval waits until one view answers it, by its id and its turn's id. When both answer, the
first answer counts. The second gets AlreadySettled, or Answered with the first view's answer,
which differs from its own when the two views disagreed. The other view then gets
CallbackResolved and removes its question:
async def answer(self, approval: agents.OpenApproval, decision: agents.AllowApproval | agents.DenyApproval) -> None:
match await self.session.turn(approval.turn_id).answer(approval.id, decision):
case agents.Answered(answer=applied) if applied != decision:
self.out(f" {DIM}the web view answered first{RESET}")
case agents.AlreadySettled():
self.out(f" {DIM}the web view answered first{RESET}")async def answer(request: Request) -> JSONResponse:
from_the_page(request)
body = await request.json()
decision = agents.AllowApproval() if body["allow"] else agents.DenyApproval()
match await session.turn(body["turn"]).answer(body["id"], decision):
case agents.Answered(answer=applied) if applied != decision:
return JSONResponse({"result": "the terminal answered first"})
case agents.AlreadySettled():
return JSONResponse({"result": "the terminal answered first"})
return JSONResponse({"result": "done"})The model can call several tools at once, so several approvals can be open together. The terminal
asks about them one at a time. a (always) allows the tool for the rest of the session, here
file_system.bash: every later command runs unasked, chained ones included, and so do the other
open calls of that tool. a on an edit allows that tool the same way: later edits to git's
settings run unasked too.
Each route except the page itself first checks two things. A page from another website, open in
the same browser, could otherwise read your code from 127.0.0.1:8765 or post a task that edits your
files:
- The run's token. Each run makes a new one and prints the link with it in the terminal. The page sends it back with each request.
- The request's
Origin. A request sent by another website's page carries that website's origin.
def from_the_page(request: Request) -> None:
"""Refuse a request without this run's token, or sent by another website's page: it could read
your code or start a turn that edits your files."""
token = request.headers.get("x-token") or request.query_params.get("token", "")
if not secrets.compare_digest(token, TOKEN) or request.headers.get("origin", URL) != URL:
raise HTTPException(403, "only the web view's own page can send this")Steer or stop the running turn
A line typed while the agent works does not wait for the turn's end. send_message() starts a turn
when none runs, and otherwise adds the line to the running turn, which reads it at its next step.
Ctrl-C interrupts the turn; the session stays open for the next task:
elif line.strip():
self.prompting = False
if self.turn_id is not None:
self.out(f" {DIM}↳ steering the running turn{RESET}")
await self.session.send_message(line) # starts a turn, or steers the running one
self.prompt() # an open approval still waits for y/n/aasync def stop(self) -> None:
if self.turn_id is not None:
await self.session.turn(self.turn_id).interrupt()The web view's form and Stop button call the same two methods.
Serve the web view in the session's loop
A session started in your process is reachable only from the event loop that started it. So the web server runs as a task in that loop, next to the terminal, rather than in a thread:
async def main() -> None:
session = await coder.start_session()
terminal = Terminal(session)
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGINT, lambda: asyncio.ensure_future(terminal.stop())) # Ctrl-C stops the turn
print(
f"{BOLD}mini-vibe{RESET} {DIM}· {os.path.basename(os.getcwd())} · {coder.model} · web view: {web.LINK}{RESET}"
)
print(f"{BOLD}>{RESET} ", end="", flush=True)
browser = web.server(session)
tasks = [asyncio.create_task(terminal.show()), asyncio.create_task(browser.serve())]
try:
await read_lines(terminal)
finally:
browser.should_exit = browser.force_exit = True # an open browser tab does not hold the exit
tasks[0].cancel()
await asyncio.gather(*tasks, return_exceptions=True)
await session.cancel()web.server returns a uvicorn.Server whose capture_signals does nothing, so Ctrl-C reaches the
terminal's handler and stops the turn, not the web view.
Mini Vibe's code
The example is a project with its own pyproject.toml:
| File | What it shows |
|---|---|
mini_vibe.py | The agent and its permission policy, the terminal view, steering and stopping |
web.py | The web view's server: the event stream, messages, answers and Stop |
page.html | The browser page, which draws the events and posts the answers |
test_mini_vibe.py | The permission policy's test: which commands run and which ask, with a scripted model |
Run it
Install the example once, from its folder:
uv syncThen, from the repository you want to work on:
export MISTRAL_API_KEY=...
export MINI_VIBE=~/path/to/mini_vibe # the example's folder
ALLOW_DANGEROUS_LOCAL_HOST=1 uv run --project "$MINI_VIBE" python "$MINI_VIBE/mini_vibe.py"Open the web view link the terminal prints, http://127.0.0.1:8765/?token=…, to follow the session in a browser. Type y, n or a to answer a
question in the terminal, or click Allow or Deny in the tab. Ctrl-D quits.
Output
In the run above, the browser tab showed each call as one line, and the command waiting for an answer:

At the end of the turn, the tab showed the agent's summary:

The change it made in the repository, from git diff:
Adapt it
- Run the commands in a container instead of on your machine: give the Agent a
LocalDockerSandbox. - Ask before edits too: change the first rule's
decisionto"ask". - Allow more commands without asking, such as your test runner: add them to
READ_ONLY.