Skip to Content
ReferenceIPC methods

IPC methods

Declared in packages/shared/src/ipc/protocol.ts; handled in apps/core/src/server.ts. For how the transport works — the two kinds of line sharing one pipe, why streaming is out of band — read the IPC protocol. This page is the listing.

All 71 implemented methods are declared in METHODS. The ✓ column marks that shared compile-time contract. Required fields also have a runtime contract in REQUIRED_PARAMS; the server checks it before dispatch.

Errors

CodeMeaning
-32601method not found
-32603the handler threw; message is the error’s text
-32700malformed JSON (web transport)
-32002REQUEST_ALREADY_RESOLVED — a prompt was answered by someone else first
-32001unauthorized (web transport)

-32602 names the missing or mistyped required field. Validation is deliberately narrow: optional and unknown fields remain the handler’s responsibility for forward compatibility. A line that fails to parse on stdin is logged to stderr and dropped — it has no id to answer.

Tools

✓MethodParamsResult
✓tools.list—ToolListItem[]
✓tools.call{ name, args }ToolResult — throws on failure

tools.call runs a tool directly, outside the agent loop and outside the permission engine, with cwd = process.cwd().

Session lifecycle

✓MethodParamsResult
✓session.startSessionConfig — { projectPath, provider, model?, agentMode? }{ sessionId }
✓session.send{ sessionId, message, images?, model?, agentMode? }LoopResult, or { queued: true, id }
✓session.stop{ sessionId }void
✓session.dequeue{ sessionId, id }{ removed: boolean }
✓session.compact{ sessionId }{ compacted, tokensBefore, tokensAfter, reason? }
✓session.list{ projectPath?, status? }SessionMeta[]
✓session.resume{ sessionId, agentMode? }{ sessionId, messages }
✓session.switch{ sessionId }void
✓session.fork{ sessionId }string — the new session id
✓session.archive{ sessionId }void
✓session.delete{ sessionId, purge? }void
✓session.getInterrupted—{ sessionId, messageId } | null

Notes that bite people:

  • session.start silently falls back to process.cwd() when projectPath is missing or does not exist.
  • session.send is the long call. If a turn is already running for that session the prompt is queued, not raced, and the call resolves immediately with { queued: true, id } plus a message_queued event. Queuing a message with images throws instead — dropping them silently would be data loss.
  • session.stop aborts the turn, not the session. The mapping is kept so the conversation stays continuable after Ctrl+C.
  • session.resume seeds provider and model from config.json when the stored session has none, and throws if neither has one.
  • session.delete disposes six per-session caches. It is the only place that does, which is why ending a session any other way leaks them.
  • session.list never carries transcripts. The handler’s type says SessionContext[], but messages is hard-coded to [] (manager.list()), so the declared SessionMeta[] is the honest shape. Use session.resume to get messages.

Session tree and rewind

✓MethodParamsResult
✓session.tree{ sessionId }tree entries with previews, labels and active-path markers
✓session.navigate{ sessionId, entryId, summarize? }{ messages, abandoned, summarized }
✓session.label{ sessionId, entryId, label }void
✓session.checkpoints{ sessionId }checkpoint summaries keyed by tree entry id
✓session.rewindPreview{ sessionId, entryId }paths and statuses a rewind would restore
✓session.rewind{ sessionId, entryId, files?, conversation?, summarize? }restored/skipped paths plus the active messages

Navigation changes the conversation leaf only. Rewind can restore the associated shadow-git snapshot first; the optional flags let a caller request either half. Both refuse to race a running turn.

Session transfer

✓MethodParamsResult
✓session.export{ sessionId }ExportedSession
✓session.import{ url }{ sessionId }
✓session.upload{ sessionId, endpoint, apiKey? }string — the remote URL
✓session.download{ url, endpoint?, apiKey? }string

Claude Code import

✓MethodParamsResult
✓session.claudeList{ projectPath?, limit? }ClaudeSessionMeta[]
✓session.claudeTranscript{ sessionId }ClaudeTranscript

Read-only: core never writes to $CLAUDE_CONFIG_DIR.

Providers and models

✓MethodParamsResult
✓providers.list{ kind?: "api" | "web" }{ id, name, description, hasApiKey }[]
✓models.list{ providerId }provider model list
✓models.contextLimit{ provider, model }number

hasApiKey is the whole reason providers.list is shaped this way — the key itself never crosses the pipe.

Config

✓MethodParamsResult
✓config.get—~/.freecode/config.json, redacted
✓config.setApiKey{ provider, apiKey, model? }void
✓config.setWebCredential{ provider, credential }void
✓config.getCurrentModel—current provider/model pair
✓config.setCurrentModel{ provider, model }void
✓config.getLastAgentMode—mode or undefined
✓config.setLastAgentMode{ mode }void
✓settings.list{ cwd }SettingView[] — the /settings rows, effective value, locked reason
✓settings.set{ key, value }void — writes ~/.freecode/settings.json; rejects an unknown key or value

config.get returns the file with every secret replaced by whether it is set: each provider entry becomes { hasApiKey, model?, authMode? } and each web entry { hasCredential }. current, lastAgentMode and recovery pass through unchanged. The redaction is an allowlist built field by field, not a blocklist of known secret names, so a credential field added later is excluded by default rather than leaked until someone remembers it.

That matters because the same method is reachable over the web transport’s POST /api, whose host is a parameter — a backend bound to 0.0.0.0 would otherwise hand out API keys.

Memory

✓MethodParamsResult
✓memory.list{ projectPath?, type? }MemoryEntry[]
✓memory.get{ name, type, projectPath? }MemoryEntry | null
✓memory.save{ entry, projectPath? }void
✓memory.delete{ name, type, projectPath? }boolean
✓memory.query{ query, projectPath?, limit?, types? }MemoryEntry[] — relevance-ranked
✓memory.buildPrompt{ projectPath?, types?, limit?, all? }string — the whole memory block, unranked
✓memory.graph.stats{ projectPath? }graph stats
✓memory.graph.rebuild{ projectPath? }graph stats, after rebuilding
✓graph.explore—{ url } or { error: "not-installed" }

projectPath defaults to process.cwd() everywhere here. memory.query routes through the graph service (semantic top-k, then a graph walk), falling back to the keyword scorer when embeddings are unavailable. Note the odd one out: graph.explore is the only memory method not under the memory. prefix, and it ignores projectPath entirely.

Prompts

✓MethodParamsResult
✓question.answer{ requestId, answers }void
✓question.reject{ requestId }void
✓permission.answer{ requestId, decision, editedRule? }void
✓permission.reject{ requestId }void

decision is one of allow-once, allow-session, allow-project, allow-always, deny. The last two write a rule into settings.json (project and user scope respectively). Answering a request that someone else already answered returns -32002, not a generic failure — with two frontends watching one session, losing the race is state, not an error.

Unanswered prompts time out after 30 minutes, and a timeout resolves to deny. If no frontend is listening at all, the ask is rejected immediately — headless never means silent allow.

Background work

✓MethodParamsResult
✓shells.list{ sessionId }ShellSummary[]
✓shells.output{ sessionId, shellId, cursor? }output plus the next cursor
✓shells.kill{ sessionId, shellId }{ killed }
✓shells.remove{ sessionId, shellId }{ removed } — settled shells only
✓agents.list{ sessionId }AgentSummary[] for the root session’s tree
✓agents.output{ sessionId, agentId, cursor? }replayable output plus the next cursor
✓agents.stop{ sessionId, agentId }{ stopped }
✓agents.remove{ sessionId, agentId }{ removed } — settled agents only

Both output methods are positional reads. Opening a frontend panel does not advance the cursor used by the model.

Extensions

✓MethodParamsResult
✓extensions.list—loaded extension sources, scopes, tools, commands, hooks and errors
✓extensions.reload—reload errors by source

Reload re-imports extension files and re-registers their tools, commands and hooks. This is process-wide rather than scoped to one session.

Everything else

✓MethodParamsResult
✓commands.list{ projectPath }CommandInfo[]
✓commands.resolve{ name, args, projectPath }{ prompt } — throws if unknown
✓skills.list{ projectPath? }{ name, description?, scope }[]
✓plugins.list—{ id, name, version?, installPath }[]
✓mcp.status{ name? }{ name, type, enabled, status, toolCount, tools }[]
✓history.list—string[]
✓history.append{ text }void
✓usage.get—{ date, tokencount }[]
✓context.stats{ sessionId }token occupancy by prompt category
✓night.list—NightRunSummary[] — every freecode night run, crashed ones reconciled; needsHuman counted in core
✓night.report{ runId? }{ runId, markdown } — regenerated from the run’s logs; newest run when omitted
✓night.stop{ runId }{ stopped } — sets cancelRequested; false unless the run is running or pending

The night.* methods only read what a separate freecode night process left on disk; they boot none of the backend, and the daemon never runs a night itself.

Stream events

Emitted during session.send, one JSON object per line, no JSON-RPC envelope. sessionId is stamped by the bus bridge on every variant; a client driving one session can ignore it, a client multiplexing several cannot.

EventFieldsWhen
tool_starttoolCallId, toolName, argsa tool call begins
tool_outputtoolCallId, contentincremental tool output
tool_completetoolCallId, toolName, result, success, duration_ms?a tool call ends
text_deltadeltaincremental assistant text (streaming)
textcontentfull assistant text at turn end
thinking_deltadeltaincremental reasoning
thinkingcontentfull reasoning at turn end
donecontentthe turn finished
errorcontentthe turn failed
noticelevel (info | warn), contentadvisory that does not fail the turn
message_queuedid, contenta send arrived mid-turn and was parked
message_dequeuedida queued message was removed or started
memory_savedmemories[{ type, name }]a memory was written without being asked — never for the memory tool
memory_injectedmemories[{ type, name }]retrieval put memories into this turn’s prompt; once per user message, not per inner turn
cache_statusstate (cold | warm | miss), message?, cacheReadTokens?, cacheWriteTokens?prompt-cache awareness. miss is the alarm: the prefix broke with no recorded cause
usage_totalstotalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheWriteTokens?once per completed turn
compaction_starttrigger (auto | manual)compaction began
compaction_completetrigger, compacted, tokensBefore, tokensAfter, reason?compaction ended
question_askedrequestId, questions[]the agent needs an answer — reply with question.answer
permission_askedrequestId, toolName, args, description, suggestedRule?, reason?approval needed — reply with permission.answer

Two rendering rules worth encoding once in a client:

  • text and text_delta are not additive. On the streaming path you get the deltas and a final text snapshot; concatenating both duplicates the turn.
  • memory_saved arrives after done. Extraction is fire-and-forget, so treat it as an out-of-band notice rather than part of the turn.

totalInputTokens already includes cache writes — totalCacheWriteTokens is the same tokens broken out for the hit-rate display, so summing them double-counts.

The older StreamResponse union (text / code / tool / done / error) still exists in protocol.ts for backward compatibility. Prefer StreamEvent.

Known gaps

Also tracked in TODO.md and shared with the IPC internals page.

  • graph.explore breaks the naming convention and hard-codes process.cwd() while every neighbouring memory method takes projectPath. It should be memory.graph.explore with the same parameter.