Engine & Turn Loop
The engine is the brain of Nehanda CLI. It runs entirely in-process as a single Node.js application — no separate server daemon, no background HTTP relay. Every user interaction flows through runUserTurn, the central function in lib/orchestrate.mjs.
The Turn Lifecycle
User types a message
│
▼
┌─────────────────────┐
│ UserPromptSubmit │ Hook: can block the prompt
│ hook fires │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ Append user │ Written to transcript_entries
│ message to DB │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ Resolve provider │ activeProvider(settings)
│ & model │ resolveWireModel(model_config)
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ Build system │ Phase-specific prompt +
│ prompt + tools │ filtered tool list
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ Stream API call │ Anthropic SDK or OpenAI-compat
│ to provider │ (with or without [TOOL_CALL] rescue)
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ Parse response │ Extract text + tool_use blocks
└─────────┬───────────┘
│
┌────┴────┐
│ │
Tool calls Plain text
│ │
▼ ▼
┌──────────┐ Return
│ Execute │ response
│ each tool│ to user
└────┬─────┘
│
▼
┌─────────────────────┐
│ Append tool │ Tool results written to
│ results to DB │ transcript_entries
└─────────┬───────────┘
│
▼
Loop back to
"Stream API call"
runUserTurn — The Main Entry Point
runUserTurn(db, rt, userText, io) is the single function that drives every interaction. It receives:
| Parameter | Type | Description |
|---|---|---|
db |
better-sqlite3 |
The SQLite database connection |
rt |
object | Runtime context: sessionId, conversationId, cwd, settings, _model |
userText |
string | The user’s input message |
io |
object | I/O adapter: write(), println(), ask(), spinner, onToolStart(), onToolResult() |
Steps
-
Hook:
UserPromptSubmit— Fires before anything else. A hook can block the prompt entirely (e.g., content policy). -
SDLC routing — If the conversation is in
idlephase and the I/O layer supports asking, the user is prompted: “Use SDLC workflow (plan → implement → test)?” If yes, the phase transitions toexploreand the user’s message is wrapped in an exploration prompt template. If no, the message is passed through as-is for a free-form turn. -
Provider resolution — The active provider is read from settings, and the wire model name is resolved. This determines whether the Anthropic SDK path or the OpenAI-compatible path is taken.
-
Provider dispatch — Control flows to either
runSdlcAnthropic()orrunSdlcOpenAICompat(). Both implement the same phase-driven loop but use different SDKs.
The Phase Loop
Inside the provider-specific functions, a while loop drives the SDLC phases:
while (['explore', 'planning', 'implement', 'test'].includes(phase)) {
const text = await runPhase(db, rt, io, hookRt, ...tools, phase)
if (text === null) return // model error
let advanced = false
if (phase === 'explore') advanced = await exploreGate(...)
if (phase === 'planning') advanced = await planGate(...)
if (phase === 'implement') advanced = await implementGate(...)
if (phase === 'test') advanced = await testGate(...)
if (!advanced) break
phase = getPhase(db, conversationId)
}
Each phase in the loop:
- Builds a phase-specific system prompt (see SDLC Workflow for prompt templates)
- Filters tools by phase (explore → read-only; planning → none; implement/test → all)
- Sends the transcript to the API with streaming enabled
- Collects the response (text + any tool calls)
- If tool calls exist, executes each one through the tool orchestrator
- Repeats until the model stops calling tools
- Passes the final text to the appropriate human gate
Tool Execution Pipeline
For each tool call the model produces:
Model emits tool_use block
│
▼
┌─────────────────────┐
│ PreToolUse hook │ Can block the tool call
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ Permission Gate │ evaluatePermission(): allow, deny, or ask
└─────────┬───────────┘
│
┌────┼────────┐
│ │ │
deny ask allow
│ │ │
▼ ▼ ▼
Error Prompt Execute
result user tool
│ │ │
│ │ ▼
│ │ ┌──────────────────┐
│ │ │ Execute tool │
│ │ │ (built-in or │
│ │ │ dynamic/MCP) │
│ │ └────────┬─────────┘
│ │ │
│ │ ▼
│ │ ┌──────────────────┐
│ │ │ PostToolUse │
│ │ │ or │
│ │ │ PostToolUse │
│ │ │ Failure hook │
│ │ └──────────────────┘
│ │
▼ ▼
Append tool_result to transcript_entries
Phase-based Tool Filtering
Before sending tools to the model, the orchestrator filters them:
| Phase | Available Tools | Rationale |
|---|---|---|
explore |
Read, Glob, Grep + explore_only dynamic tools |
Only discovery — no mutations |
planning |
None | Model writes plan as text, no tool calls |
implement |
All tools | Plan approved, execute freely |
test |
All tools | Run tests + mandatory safety checkers |
idle |
All tools (with permission gate) | Free-form interaction |
Local-only Tool Subset
When connected to local models (LM Studio, or Ollama without native tool support), the tool set is reduced to six core tools to conserve context window: Read, Write, Edit, Bash, Glob, Grep. Web tools, notebook tools, worktree tools, and agent tools are excluded.
Subagent Spawning
The Agent tool creates a child turn within the same process. The subagent:
- Gets its own
session_id(UUID) but shares theconversation_id - Runs
runUserTurnrecursively with the subagent’s prompt - Collects all output into a string
- Fires
SubagentStartandSubagentStophooks - Returns the collected output as the tool result
This enables the model to delegate subtasks without any external process management.
Error Handling
- Provider errors are classified by HTTP status (401 →
authentication_failed, 429 →rate_limit, 404 →model_not_found, 500/503 →server_error) and reported via theStopFailurehook. - Tool execution errors return
is_error: truein the tool result, which the model sees and can respond to. - Permission denials return an error tool result explaining the decision.
- Phase violations (calling a blocked tool in the wrong phase) return an error immediately without executing.
- Ollama fallback — if an Ollama model returns a “does not support tools” error, the engine automatically falls back to the
[TOOL_CALL]rescue path for all subsequent turns.
The io Adapter
The I/O layer abstracts away whether the engine is running in an interactive TUI or headless pipe mode:
| Method | Interactive (Ink TUI) | Pipe Mode |
|---|---|---|
io.write(text) |
Renders in the terminal component | Writes to stdout |
io.println(text) |
Renders with newline | Writes to stdout |
io.ask(question) |
Shows inline prompt, waits for input | Returns 'y' (auto-approve) |
io.spinner.start(msg) |
Shows spinning indicator | No-op |
io.spinner.stop() |
Hides spinner | No-op |
io.onToolStart(name) |
Updates tool indicator UI | No-op |
io.onToolResult(name, content, isError) |
Updates tool result display | No-op |
This is why pipe mode can run fully unattended — all interactive prompts auto-approve.