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#
1 | |
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.
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
interruptinstead.
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#
1 2 3 4 5 6 | |
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#
# 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_changedfor slash-induced model/mode/gadget swaps yet. Inspectslash_result.datafor 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 serveas a real subprocess against a mock gateway is on the follow-on list.