> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mirage.strukto.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Embedded Terminal

> A lightweight terminal inside a FastAPI or Express server, with Python on Monty and no container.

Most of the time an agent does not need a sandbox, only a terminal-like
interface. An embedded terminal runs inside an application server. Each terminal is a
Mirage session with its own working directory, environment and history, and
[Monty](https://docs.mirage.strukto.ai/home/setup/runtime/monty.md) runs Python in-process, so a terminal
starts with no container or VM. Mirage is async native, so one server process
runs many terminals at once on its event loop.

## Installation

**FastAPI**

```bash theme={null}
pip install 'mirage-ai[monty]' fastapi uvicorn
```

**Express**

```bash theme={null}
npm install @struktoai/mirage-node @pydantic/monty express
```

## Server

One endpoint creates a session, another runs a command line in it.

**FastAPI**

```python app.py theme={null}
from contextlib import asynccontextmanager

from fastapi import FastAPI
from pydantic import BaseModel

from mirage import RAMVFS, MountMode, Workspace
from mirage.runtime.python.monty import MontyRuntime

ws = Workspace(
    {"/work": (RAMVFS(), MountMode.EXEC)},
    runtimes=[MontyRuntime(captures=["python", "python3"])],
)


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await ws.close()


app = FastAPI(lifespan=lifespan)


class Run(BaseModel):
    command: str


@app.post("/sessions/{session_id}")
async def create(session_id: str) -> None:
    ws.create_session(session_id)


@app.post("/sessions/{session_id}/run")
async def run(session_id: str, body: Run) -> dict:
    r = await ws.shell(body.command, session_id=session_id)
    return {"stdout": await r.stdout_str(), "stderr": await r.stderr_str(), "exit_code": r.exit_code}
```

```bash theme={null}
uvicorn app:app --port 3000
```

**Express**

```typescript app.mjs theme={null}
import express from 'express'
import { MontyRuntime, MountMode, RAMVFS, Workspace } from '@struktoai/mirage-node'

const ws = new Workspace(
  { '/work': new RAMVFS() },
  { mode: MountMode.EXEC, runtimes: [new MontyRuntime({ captures: ['python', 'python3'] })] },
)
const app = express()
app.use(express.json())

app.post('/sessions/:id', (req, res) => {
  ws.createSession(req.params.id)
  res.sendStatus(201)
})

app.post('/sessions/:id/run', async (req, res) => {
  const r = await ws.shell(req.body.command, { sessionId: req.params.id })
  res.json({ stdout: r.stdoutText, stderr: r.stderrText, exitCode: r.exitCode })
})

app.listen(3000, '127.0.0.1')
```

```bash theme={null}
node app.mjs
```

## Usage

```bash theme={null}
curl -X POST localhost:3000/sessions/a
curl -X POST localhost:3000/sessions/a/run -H 'content-type: application/json' \
  -d '{"command": "cd /work && echo \"print(sum(range(10)))\" > s.py && python3 s.py"}'
```

The run prints `45`. A later call to session `a` starts in `/work`, where the
`cd` left it.

* Sessions share the workspace's mounts, so a file one writes under `/work`
  is visible to the others. Give each session its own directory, or its own
  workspace, to keep files apart.
* Monty supports a subset of Python and has no network or host access. For
  more, see [Hybrid Terminal](#hybrid-terminal).
* The server runs any line for any caller that names a session. Put
  authentication and a check that the caller owns the session in front of it
  before it listens beyond localhost.

## Async

`ws.shell` is a coroutine in Python and a promise in TypeScript, and it never
blocks the event loop. The handlers above are `async`, so while one terminal
waits on a file read, a runtime or a slow command, the server keeps serving
every other terminal, with no thread or process per terminal.

**Python**

```python theme={null}
import asyncio

for sid in ["a", "b", "c"]:
    ws.create_session(sid)
await asyncio.gather(*(ws.shell("sleep 1", session_id=sid) for sid in ["a", "b", "c"]))
```

**TypeScript**

```typescript theme={null}
for (const sid of ['a', 'b', 'c']) ws.createSession(sid)
await Promise.all(['a', 'b', 'c'].map((sid) => ws.shell('sleep 1', { sessionId: sid })))
```

The three lines finish together in about a second; 20 sessions take the
same. Within one session, lines run one after another in order, as in a real
terminal, so three `sleep 1` lines on session `a` take three seconds.

## Hybrid Terminal

A hybrid terminal keeps light Python on Monty and sends the rest to a Docker
container: lines that use `numpy` or `pandas` by route policy, and every
program Mirage does not have itself, such as `ffmpeg`, by `@external`. Redis
holds the files, sessions, jobs and processes, so a terminal outlives the
server process that runs it.

| Line | Runs on |
| - | - |
| `cat`, `grep`, pipes, redirects | Mirage |
| `python3 s.py` | Monty |
| `python3 -c 'import numpy; ...'` | Docker, by the route policy |
| `ffmpeg -i in.mp4 out.gif` | Docker, by `@external` |

Build the container's image with `sshfs`, which serves the workspace into it,
and the programs the lines need, and start Redis:

```dockerfile Dockerfile theme={null}
FROM python:3.12-slim
RUN apt-get update \
    && apt-get install -y --no-install-recommends sshfs ffmpeg \
    && rm -rf /var/lib/apt/lists/* \
    && pip install --no-cache-dir numpy pandas
```

```bash theme={null}
docker build -t mirage-box .
docker run -d --name redis -p 6379:6379 redis
```

A container serves one session, so each terminal gets a workspace and a
container of its own. The server starts the container when it creates the
terminal:

**FastAPI**

```bash theme={null}
pip install 'mirage-ai[monty,redis,ssh]' fastapi uvicorn
```

```python app.py theme={null}
import asyncio
import re
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from mirage import MountMode, Workspace
from mirage.runtime.python.monty import MontyRuntime
from mirage.runtime.sandbox.docker import DockerRuntime
from mirage.vfs.redis import RedisVFS
from mirage.workspace.store import RedisWorkspaceStateStore

REDIS_URL = "redis://localhost:6379/0"
terminals: dict[str, Workspace] = {}


def terminal(terminal_id: str) -> Workspace:
    if terminal_id not in terminals:
        terminals[terminal_id] = Workspace(
            {"/work": RedisVFS(url=REDIS_URL)},
            mode=MountMode.EXEC,
            workspace_id=terminal_id,
            store=RedisWorkspaceStateStore(url=REDIS_URL),
            owns_store=True,
            runtimes=[
                MontyRuntime(captures=["python", "python3"]),
                DockerRuntime(
                    captures=["python", "python3", "@external"],
                    config={"container": f"box-{terminal_id}", "mount_type": "ssh"},
                ),
            ],
            route_policy=lambda ctx: "docker" if re.search(r"\b(numpy|pandas)\b", ctx.line) else None,
        )
    return terminals[terminal_id]


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    for ws in terminals.values():
        await ws.close()


app = FastAPI(lifespan=lifespan)


class Run(BaseModel):
    command: str


@app.post("/sessions/{session_id}")
async def create(session_id: str) -> None:
    docker = await asyncio.create_subprocess_exec(
        "docker", "run", "-d", "--cap-add", "SYS_ADMIN", "--device", "/dev/fuse",
        "--name", f"box-{session_id}", "mirage-box", "sleep", "infinity",
    )
    if await docker.wait() != 0:
        raise HTTPException(500, "could not start the terminal's container")
    terminal(session_id).create_session(session_id)


@app.post("/sessions/{session_id}/run")
async def run(session_id: str, body: Run) -> dict:
    r = await terminal(session_id).shell(body.command, session_id=session_id)
    return {"stdout": await r.stdout_str(), "stderr": await r.stderr_str(), "exit_code": r.exit_code}
```

**Express**

```bash theme={null}
npm install @struktoai/mirage-node @pydantic/monty redis ssh2 express
```

```typescript app.mjs theme={null}
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import express from 'express'
import {
  DockerRuntime,
  MontyRuntime,
  MountMode,
  RedisVFS,
  RedisWorkspaceStateStore,
  Workspace,
} from '@struktoai/mirage-node'

const redisUrl = 'redis://localhost:6379/0'
const terminals = new Map()

function terminal(id) {
  if (!terminals.has(id)) {
    terminals.set(
      id,
      new Workspace(
        { '/work': new RedisVFS({ url: redisUrl }) },
        {
          mode: MountMode.EXEC,
          workspaceId: id,
          store: new RedisWorkspaceStateStore({ url: redisUrl }),
          ownsStore: true,
          runtimes: [
            new MontyRuntime({ captures: ['python', 'python3'] }),
            new DockerRuntime({
              captures: ['python', 'python3', '@external'],
              config: { container: `box-${id}`, mountType: 'ssh' },
            }),
          ],
          routePolicy: (ctx) => (/\b(numpy|pandas)\b/.test(ctx.line) ? 'docker' : null),
        },
      ),
    )
  }
  return terminals.get(id)
}

const app = express()
app.use(express.json())

app.post('/sessions/:id', async (req, res) => {
  const id = req.params.id
  await promisify(execFile)('docker', [
    'run', '-d', '--cap-add', 'SYS_ADMIN', '--device', '/dev/fuse',
    '--name', `box-${id}`, 'mirage-box', 'sleep', 'infinity',
  ])
  terminal(id).createSession(id)
  res.sendStatus(201)
})

app.post('/sessions/:id/run', async (req, res) => {
  const r = await terminal(req.params.id).shell(req.body.command, { sessionId: req.params.id })
  res.json({ stdout: r.stdoutText, stderr: r.stderrText, exitCode: r.exitCode })
})

app.listen(3000, '127.0.0.1')
```

* Send each terminal's requests to one replica, for example by hashing the
  terminal id at the load balancer. A replica keeps a session in memory once
  it has loaded it, so two replicas serving one terminal would each keep
  their own working directory. After a restart, the replica reads the
  terminal back from Redis, in the directory it left.
* Every terminal's `/work` is the same Redis keys, so terminals share files.
  Give each `RedisVFS` its own `key_prefix` to keep them apart.
* Remove a terminal's container with `docker rm -f box-<id>` when the
  terminal ends.
* `ws.explain(line)` shows which runtime serves each command.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.