---
title: Serve mode protocol
path: reference/serve-protocol
status: published
---

# ScaiFlux serve mode

`scaiflux serve` runs ScaiFlux as a long-lived process driven by an
external controller (e.g. [ScaiForge](#)) over **NDJSON on stdio**.
One process == one session. The controller speaks commands on stdin
and reads events from stdout; stderr is for human-readable logs and
is never multiplexed with the protocol channel.

This is the channel for programmatic orchestration. For interactive
human use, drop into `scaiflux repl` instead.

## At a glance

```bash
scaiflux serve --workspace /path/to/project --model scailabs/poolnoodle-omni
```

The controller writes one JSON object per line on stdin, and receives
one JSON object per line on stdout. Newlines inside a payload's
strings are escaped — every `\n` byte on the wire is a record
boundary.

```mermaid
sequenceDiagram
    autonumber
    participant C as controller
    participant S as ScaiFlux (serve)
    C->>S: spawn (stdio pipes)
    S-->>C: emits: ready
    C->>S: prompt
    S-->>C: emits: turn_started
    S-->>C: emits: text_delta × N
    S-->>C: emits: tool_start
    S-->>C: emits: permission_request
    C->>S: permission_response
    S-->>C: emits: tool_result
    S-->>C: emits: text_delta × N
    S-->>C: emits: turn_complete
    C->>S: shutdown
    Note over S: (process exits)
```

## Protocol

The schema is the **source of truth**: see
[`scaiflux/cli/protocol.py`](./scaiflux/cli/protocol.py). Every
message is a Pydantic v2 model with `extra="forbid"` — unknown fields
fail on both write and read. The current protocol version is **1**
(in `ReadyEvent.protocol`).

### Inbound — controller → ScaiFlux

| `type` | Fields | Effect |
|--------|--------|--------|
| `prompt` | `text: str` | Start a new turn with `text` as the user message. Rejected (with an `error` event) if a turn is already in flight. |
| `slash` | `command: str`, `args: str = ""` | Run a slash command. `command` is the name without the leading slash; `args` is the rest of the line. Emits `slash_result` on completion. |
| `interrupt` | — | ESC equivalent. Cancels the active turn's cancel scope. Turn finishes with `stop_reason: "interrupted"`. No-op if no turn is in flight. |
| `permission_response` | `request_id: str`, `decision: "allow"\|"deny"`, `reason: str?` | Reply to a `permission_request` event. Unknown `request_id` triggers an `error`. |
| `shutdown` | — | Graceful exit. Cancels any pending permission futures and closes. |

Closing stdin (EOF) is equivalent to `shutdown`.

### Outbound — ScaiFlux → controller

| `type` | When emitted | Key fields |
|--------|--------------|------------|
| `ready` | First event after bootstrap | `protocol`, `version`, `session_id`, `model`, `mode`, `tools`, `context_window`, `max_tokens`, `gadgets` |
| `turn_started` | Start of every turn | `turn_id` |
| `text_delta` | Streaming model output | `turn_id`, `delta` |
| `tool_start` | Model invoked a tool | `turn_id`, `tool_id`, `name`, `input` |
| `tool_result` | Tool finished | `turn_id`, `tool_id`, `output`, `is_error`, `duration_s` |
| `permission_request` | Tool call needs permission | `request_id`, `tool`, `active_mode`, `required_mode`, `details.tool_input`, `details.reason` |
| `turn_complete` | Turn ended | `turn_id`, `stop_reason`, `iterations`, `input_tokens`, `output_tokens`, `mutations: list[(tool, target)]` |
| `slash_result` | A `slash` command finished | `command`, `text`, `data`, `clear_session`, `exit` |
| `state_changed` | Model/mode/gadgets/context% changed | `model?`, `mode?`, `context_pct?`, `context_window?`, `gadgets?` (every field optional) |
| `error` | A turn failed, or an inbound message was malformed | `code`, `message`, `origin: "local"\|"remote"\|"unknown"`, `turn_id?` |

`stop_reason: "interrupted"` distinguishes a controller-cancelled
turn from a natural `end_turn` finish.

## Permissions

Every permission decision is **proxied** to the controller — the
local `.scaiflux.json` policy is bypassed in serve mode. The
controller is expected to either auto-decide based on tool args or
forward to a human. On `decision: "deny"`:

- The tool call is **not** executed.
- The model receives a synthetic tool-error result containing the
  controller-supplied `reason` (or `"denied by remote controller"`).
- The **turn continues** — the model can adapt and try a different
  approach. This is the configured behavior; if you want a deny to
  abort the whole turn, send `interrupt` instead.

On controller disconnect (stdin EOF / crash), any pending permission
futures are cancelled, which the prompter surfaces as deny with
reason `"controller disconnected before responding"`. The current
turn finishes normally.

## Slash commands

Slash commands dispatch through the same registry as the REPL. They
do **not** count as a turn — they return immediately with a
`slash_result` event. Side-effects on runtime state (model swap,
mode change, gadget toggle) currently land directly in the runtime
but **don't** auto-emit `state_changed` yet — track via
`slash_result.data` until that follow-on lands.

Slash output is plain text (the same string the REPL would print).
Structured commands like `/context` populate `data` with a JSON
object.

## Versioning

The contract is the `extra="forbid"` Pydantic schema plus a single
integer `PROTOCOL_VERSION` (currently 1) advertised in the `ready`
event. The compatibility rules:

- **Patch / additive change**: new optional field with a default →
  no version bump. Old controllers ignore the field.
- **Breaking change**: renamed field, removed field, changed
  semantics, changed required fields → bump `PROTOCOL_VERSION`.

Controllers should check `ready.protocol` and refuse versions they
weren't built against.

## Running it

```bash
# Spawned by a controller process. Stdin/stdout are bound to pipes;
# stderr typically inherits from the controller's terminal or a log
# file.
scaiflux serve --workspace ./project \
               --model scailabs/poolnoodle-omni \
               --permission-mode prompt
```

Flags:

| Flag | Default | Purpose |
|------|---------|---------|
| `--model, -m` | (from `.scaiflux.json` or session) | Frontend model slug |
| `--max-tokens` | derived from `context_window` | Per-turn output cap |
| `--workspace, -w` | `.` | Workspace root for tool execution |
| `--permission-mode` | `danger-full-access` | Baseline mode reported in `permission_request.active_mode`. Decisions are always proxied regardless. |
| `--max-iterations` | `20` | Max tool-loop iterations per turn |
| `--resume <id\|latest\|path>` | — | Resume an existing session |

For sandboxing, the controller is expected to spawn `scaiflux serve`
inside `unshare` / `firejail` / a container — there's no in-process
sandbox flag.

## End-to-end example

```jsonl
# controller writes:
{"type":"prompt","text":"list the python files"}

# ScaiFlux replies (one line each):
{"type":"turn_started","turn_id":"abc123"}
{"type":"text_delta","turn_id":"abc123","delta":"I'll glob "}
{"type":"text_delta","turn_id":"abc123","delta":"for *.py."}
{"type":"tool_start","turn_id":"abc123","tool_id":"call_1","name":"glob_search","input":{"pattern":"**/*.py"}}
{"type":"permission_request","request_id":"r-9","tool":"glob_search","active_mode":"prompt","required_mode":"read-only","details":{"tool_input":{"pattern":"**/*.py"},"reason":null}}

# controller writes:
{"type":"permission_response","request_id":"r-9","decision":"allow"}

# ScaiFlux continues:
{"type":"tool_result","turn_id":"abc123","tool_id":"call_1","output":"a.py\nb.py\n","is_error":false,"duration_s":0.04}
{"type":"text_delta","turn_id":"abc123","delta":"Found two: a.py and b.py."}
{"type":"turn_complete","turn_id":"abc123","stop_reason":"end_turn","iterations":1,"input_tokens":820,"output_tokens":34,"mutations":[]}

# controller writes:
{"type":"shutdown"}
# ScaiFlux exits.
```

## Limits & known gaps

- **No `state_changed` for slash-induced model/mode/gadget swaps yet.**
  Inspect `slash_result.data` for the new state until that lands.
- **No multi-session multiplexing.** One process == one session by
  design. Spawn N processes for N parallel agents.
- **No integration test bundled.** Unit tests cover the schema,
  the writer, the prompter, and dispatch; an end-to-end test
  exercising `scaiflux serve` as a real subprocess against a mock
  gateway is on the follow-on list.
