Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

Serve mode protocol

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
1
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.

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. 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
1
2
3
4
5
6
# 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.
Updated 2026-05-18 14:34:13 View source (.md) rev 4