Practical guide for AI coding agents working in this codebase. Read CLAUDE.md first for architecture overview.
- pnpm only - Never use npm/yarn. The lockfile is
pnpm-lock.yaml. - Path aliases - Main process uses
@shared/*, renderer uses@renderer/*or~/*. Never use relative paths like../../shared/. - No circular imports - Check dependency direction before adding imports. See "Circular Import Avoidance" in CLAUDE.md.
- Singleton pattern - Services use
static getInstance(). Don't create new instances; use the exported singleton. - Types in
src/shared/types.ts- Types used by BOTH main and renderer go here (~1510 lines). Types only for shared package go inpackages/shared/src/types.ts. - Build shared first - After changing
packages/shared, runpnpm build:sharedbeforepnpm dev.
- Define the handler in
tipc.ts:
const myHandler = tipc.procedure
.input(z.object({ foo: z.string() })) // Zod schema for input
.action(async ({ input }) => {
// Implementation
return result
})-
Export it in the router object at the bottom of
tipc.ts(search forexport const router =). -
Call from renderer:
const result = await window.electron.ipcRenderer.invoke('myHandler', { foo: 'bar' })- For main→renderer events, add the event type to
renderer-handlers.ts:
export type RendererHandlers = {
myEvent: (data: MyEventData) => void
// ...existing handlers
}Then emit from main: getRendererHandlers<RendererHandlers>(webContents).myEvent.send(data)
Built-in tools appear as speakmcp-settings:tool_name to the LLM.
- Add schema to
builtin-tool-definitions.ts(this file MUST stay dependency-free):
{
name: `${BUILTIN_SERVER_NAME}:my_tool`,
description: "What the tool does",
inputSchema: {
type: "object",
properties: { param: { type: "string", description: "..." } },
required: ["param"],
},
}- Add handler to
builtin-tools.tsin thetoolHandlersrecord:
const toolHandlers: Record<string, ToolHandler> = {
my_tool: async (args): Promise<MCPToolResult> => {
// Implementation - CAN import from other services
return { content: [{ type: "text", text: "result" }], isError: false }
},
// ...existing handlers
}- Create page component in
apps/desktop/src/renderer/src/pages/settings-mypage.tsx - Export
Componentas named export (for React Router lazy loading):
export function Component() { return <div>...</div> }- Add route in
router.tsx:
{ path: "settings/mypage", lazy: () => import("./pages/settings-mypage") }- Add navigation link in the settings sidebar (in
app-layout.tsx)
- Create file in
apps/desktop/src/main/my-service.ts - Use the singleton pattern:
class MyService {
private static instance: MyService | null = null
static getInstance(): MyService {
if (!MyService.instance) MyService.instance = new MyService()
return MyService.instance
}
private constructor() {}
}
export const myService = MyService.getInstance()- Import the singleton where needed. Register any IPC handlers in
tipc.ts.
- "Cannot find module @shared/...": You're in a renderer file using main-process alias. Use
import from "../../shared/..."or check which tsconfig applies. - Circular dependency:
builtin-tools.ts↔profile-service.tswas a past issue. Schemas go inbuiltin-tool-definitions.ts(no deps), handlers inbuiltin-tools.ts.
- Main and renderer are SEPARATE TypeScript compilations (
tsconfig.node.jsonvstsconfig.web.json). - Shared types must be in
src/shared/or@speakmcp/shared. - The renderer cannot import from
src/main/directly.
- Always use
agentSessionStateManagerfor session state, not rawstate.*properties. - The
state.shouldStopAgentglobal flag is legacy; prefer session-scopedshouldStopSession(sessionId). - Call
cleanupSession()in finally blocks to prevent state leaks.
- MCP tools use
server:tool_nameformat. LLM providers require^[a-zA-Z0-9_-]{1,128}$. llm-fetch.tssanitizes names (:→__COLON__) and maintains anameMapfor reverse lookup.- Never hardcode sanitized names; always use the mapping.
- Use
WINDOWS.get("main")/WINDOWS.get("panel")fromwindow.ts. - Panel window may not exist. Always null-check.
- Panel has special resize logic (
resizePanelForAgentMode,resizePanelToNormal).
Config is a flat JSON object persisted at ~/Library/Application Support/app.speakmcp/config.json (macOS).
- Read:
configStore.get()returns fullConfigobject - Write:
configStore.set(partial)merges partial updates - Migration logic in
config.tshandles schema evolution (e.g., Groq TTS model renames) - Config type defined in
src/shared/types.tsasConfiginterface
@speakmcp/shared (packages/shared/src/types.ts)
└─ ToolCall, ToolResult, BaseChatMessage, ChatApiResponse, QueuedMessage
src/shared/types.ts (apps/desktop/src/shared/types.ts)
└─ Re-exports from @speakmcp/shared
└─ Config, MCPConfig, MCPServerConfig, OAuthConfig
└─ AgentProgressStep, AgentProgressUpdate
└─ ACPAgentConfig, ACPDelegationProgress
└─ AgentProfile, Persona, Profile (unified as AgentProfile)
└─ ConversationMessage, Conversation
└─ AgentMemory, AgentStepSummary
└─ SessionProfileSnapshot, ModelPreset
LLM calls use Vercel AI SDK (ai package), NOT raw fetch:
generateText()for non-streaming tool calls (main agent loop)streamText()for streaming responses- Providers:
@ai-sdk/openai(also used for Groq via OpenAI-compatible endpoint),@ai-sdk/google - Tool schemas converted via
jsonSchema()from AI SDK - Provider created in
ai-sdk-provider.tswithcreateLanguageModel()
context-budget.ts manages token limits:
MODEL_REGISTRYmaps model names to context windows (200K for Claude, 128K for GPT-4, etc.)shrinkMessagesForLLM()trims conversation history to fit contextestimateTokensFromMessages()for rough token countingsummarizeContent()for compacting old messages
pnpm install && pnpm build-rs && pnpm dev
# First run will show onboarding flow
# Need at least one API key (OpenAI/Groq/Gemini) configured to use agent mode