Skip to main content
Status: Beta. The dispatch gate, the state machine, the /plan meta-command, the REST routes and the exposure filters are covered by feral-core/tests/test_plan_mode.py. Two gaps are known and stated on this page rather than left to be discovered: plan mode has no voice affordance, and the live realtime voice proxies bypass the dispatch gate entirely. No client currently renders the plan_mode frame the brain emits.

What plan mode is

Plan mode is a per-session, ephemeral posture: the agent researches with read-only tools, proposes a plan, and cannot change anything until you let it. It is not a fourth autonomy mode. security.autonomy_mode (strict, hybrid, loose) is persisted and global; it answers “how much confirmation does this operator want?”. Plan mode is per-session and in-memory; it answers “is this particular conversation allowed to change anything right now?”. They are orthogonal axes kept in separate state. Entering or leaving plan mode never reads or writes the autonomy mode. State lives in PlanModeState (feral-core/agents/plan_mode.py), held by the ToolRunner and reached through Orchestrator.plan_mode. It is deliberately unpersisted: plan mode is a property of a conversation in flight, not of the install, and a restart should not resurrect a posture you cannot see. Contrast security.autonomy_mode, which is persisted precisely because it is a property of the install.

Entry and exit

Entry is explicit only. There is no heuristic on your prose.
Matching is an exact first-token check on /plan, so prose about planning never enters the mode. It is intercepted before prompt routing, which would otherwise read /plan as the /skill prefix form for the plan skill. The REST equivalent, on the session resource rather than the settings resource:

The model can never exit it itself

PlanModeState.exit() takes an actor argument and refuses anything other than "user", logging a warning when it does. There is no tool that reaches it, no meta-command reachable from model output, and no argument path that reaches the method with the right actor. The REST exit route hard-codes actor="user" because it is reachable by the operator, not by the model. The system prompt block injected while plan mode is active says this explicitly, including “do not claim to have left it, and do not ask to be given write access mid-turn”. The refusal envelope returned by the dispatch gate is marked fixable_by_llm: false for the same reason: retrying is not the fix.

Plan approval grants no standing tool approval

The approved flag on exit is recorded for the UI and confers nothing. No standing approval is created, so every mutating call in the turns that follow an approved plan still goes through the session’s autonomy mode. Granting blanket approval on plan approval would be a real security regression, so the flag deliberately has no privilege attached to it. The plan.json manifest, the /plan approve reply text, the system-prompt block and the REST route docstring all say so.

Two enforcement points

1. Exposure (advisory). The orchestrator filters the tool array and the prompt’s tooling catalog after building them. This is advisory only: a model can emit a tool name it was never given, and the voice surfaces build their tool list from SkillRegistry.get_all_tools() on a path the orchestrator never touches. Skill pruning happens at endpoint granularity, not skill granularity, because a single skill routinely mixes both kinds (coding_tools has both read_file and edit_file). Skills left with no plan-safe endpoint drop out entirely. Without this, self_model.build_tooling_catalog would tell the model coding_tools__edit_file is “Active this turn” and then refuse it at dispatch, which reads as a malfunction rather than a posture. 2. Dispatch (the gate that holds). ToolRunner.enforce_plan_mode sits above the MCP branch, the subagent branch and the daemon branch in _execute_tool_call_for_llm_inner, and again on the UI-event / action-intent dispatch path, which reaches the executor without passing through the first. None of those three branches carry manifest safety metadata, and all three would otherwise be a hole straight through the mode. A refusal increments feral_plan_mode_blocked_total and returns a structured error envelope with error_code: "plan_mode_blocked".

What “plan-safe” means

An endpoint is plan-safe only when its manifest declares it: read_only_hint: true or safety_tier: "safe". Absence of metadata is not-plan-safe. That is why is_plan_safe_tool calls security.safety_resolver.is_read_only with strict=True. The lenient default falls back to a substring token list that admits any tool name containing read, status, list or current. test_plan_mode.py::test_shipped_manifests_leak_mutating_tools_under_the_legacy_list pins that the shipped manifests really do leak mutating tools under the legacy list, which is not a boundary a mode called “cannot mutate” can stand on. Beyond the declaration:
  • mcp_* and daemon_* tool names fail closed. They carry no read_only_hint or safety_tier, so there is nothing to trust.
  • subagent__spawn_subagent is in PLAN_MODE_ALWAYS_BLOCKED and is never plan-safe. Subagents run through the same dispatch gate but under a session id the parent’s plan-mode entry does not literally cover. is_active walks the :sub: ancestry that ToolRunner._run_subagent_task mints, but Orchestrator.spawn_subsession mints bare uuid4 ids that no ancestry walk can recover. So spawning is refused outright rather than relying on inheritance.
  • plan__submit is in PLAN_MODE_ALWAYS_ALLOWED. It is declared read_only_hint: true and safety_tier: "safe" in skills/manifests/plan.json and survives the filter on its own merits; the literal is there so it also survives on paths where no real registry is wired. Without it, plan mode would be a mode with no way to produce its one artefact.
Submitting a plan never enters plan mode. Submitted plans are stored in a separate map from the active-session set, deliberately: if record_plan wrote to the active set, a plan__submit call made outside plan mode (the skill is routable on its trigger phrases like any other) would flip the session into plan mode as a side effect of the model choosing a tool, which is precisely the heuristic entry this design rules out.

Plan mode has no voice affordance

This is worth stating plainly, because the enforcement story and the usability story do not match.
  • There is no way to enter plan mode from voice. /plan is handled in Orchestrator._handle_command_body and _handle_command_stream_body, which are the text paths. Reaching it by voice would require the STT engine to emit the literal token /plan as the first word of a transcript.
  • There is no way to see it from voice. The brain emits a plan_mode WebSocket frame on entry and exit, and no client currently renders it (no match for plan_mode anywhere in feral-client-v2/src). There is no spoken confirmation either.
  • Enforcement coverage on voice is partial. The chained pipeline routes through Orchestrator.handle_command_stream, so its tool calls pass through ToolRunner and are gated. The two live realtime proxies do not: voice/realtime_proxy.py and voice/gemini_realtime.py call self._skill_executor.execute(...) directly, bypassing ToolRunner.enforce_plan_mode entirely. test_plan_mode.py covers the voice case by driving execute_tool_call_for_llm with surface="voice", which is the chained shape, not the realtime one.
So: if you enter plan mode from chat and then speak into a live realtime session, mutating tool calls made on that session are not refused. If that matters to you, use the chained voice path or stay in chat.

The todo tracker

feral_workflows__todo_write is the agent’s own working list for the current conversation. It is a visible scratchpad; nothing in it is executed.

Why it is not a new skill

FERAL already ships four overlapping list concepts plus a dormant fifth: feral_reminders (the user’s time-anchored list), feral_routines (recurring, cron-backed), feral_workflows (ordered steps the brain executes as a TaskFlow), background_task (long-horizon autonomous runs), and agents/coding_run.py (a plan-then-apply loop, unwired). A sixth top-level skill would cost two tools (the endpoint plus a catalog entry the router has to rank) on a chat path that applies no tool cap at all and already carries roughly 59 always-include tools. So the todo list is one extra endpoint on feral_workflows, the concept it is most confused with, where the two descriptions can disambiguate each other in the same block of the prompt.

Semantics

  • Full-list replacement, never incremental patches, so the model’s view and the store cannot drift. Every call replaces the previous list.
  • At most one in_progress item, enforced in the store rather than suggested in prose. That is what makes it a focus mechanism rather than a wish list. More than one is rejected with multiple_in_progress.
  • At most 40 items, each capped at 500 characters. The 40 is chosen strictly below the feed result-budget tier’s max_list_len of 50 so the echo the endpoint returns can never be truncated by the sanitizer. That matters more than it looks: the model rewrites the whole list from what it last saw, so a truncated echo makes it silently drop the tail on the next write. The cap and the declared tier are tested together.
  • Statuses are pending, in_progress, completed. Ids are optional and generated when omitted; duplicates are reassigned.
  • A rejected write leaves the previous list untouched rather than half-applied: the whole list is validated before anything is stored.
Storage is in-memory per session with a durable JSON mirror at $FERAL_HOME/agent_todos.json so the UI panel survives a brain restart. No SQLite: nothing here needs cross-session queries, and the list is scratch state that should not accumulate. A corrupt mirror is logged and the store starts empty rather than stopping the brain from booting.

Defaults

New sessions do not start in plan mode. FERAL_PLAN_MODE_DEFAULT=1 flips that. There is no settings.json key for it yet: security.plan_mode_default is named in the source as the intended mirror but does not exist in DEFAULT_SETTINGS, so the environment variable is currently the only switch.