Status: Beta. The dispatch gate, the state machine, the/planmeta-command, the REST routes and the exposure filters are covered byferal-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 theplan_modeframe 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./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
Theapproved 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 fromSkillRegistry.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_*anddaemon_*tool names fail closed. They carry noread_only_hintorsafety_tier, so there is nothing to trust.subagent__spawn_subagentis inPLAN_MODE_ALWAYS_BLOCKEDand 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_activewalks the:sub:ancestry thatToolRunner._run_subagent_taskmints, butOrchestrator.spawn_subsessionmints bare uuid4 ids that no ancestry walk can recover. So spawning is refused outright rather than relying on inheritance.plan__submitis inPLAN_MODE_ALWAYS_ALLOWED. It is declaredread_only_hint: trueandsafety_tier: "safe"inskills/manifests/plan.jsonand 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.
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.
/planis handled inOrchestrator._handle_command_bodyand_handle_command_stream_body, which are the text paths. Reaching it by voice would require the STT engine to emit the literal token/planas the first word of a transcript. - There is no way to see it from voice. The brain emits a
plan_modeWebSocket frame on entry and exit, and no client currently renders it (no match forplan_modeanywhere inferal-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 throughToolRunnerand are gated. The two live realtime proxies do not:voice/realtime_proxy.pyandvoice/gemini_realtime.pycallself._skill_executor.execute(...)directly, bypassingToolRunner.enforce_plan_modeentirely.test_plan_mode.pycovers the voice case by drivingexecute_tool_call_for_llmwithsurface="voice", which is the chained shape, not the realtime one.
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_progressitem, 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 withmultiple_in_progress. - At most 40 items, each capped at 500 characters. The 40 is chosen strictly
below the
feedresult-budget tier’smax_list_lenof 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.
$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.