Ctrl K
ComunidadGuías…

CLAUDE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# OpenCompany - Claude Documentation

## Project Overview
OpenCompany is an open-source operating system for self-improving AI employees. Employees use browsers, apps, tools, integrations, and the computer to complete work. Home lets people describe a job, review its setup, hire an employee, and work with it through chat and a shared Workspace. Hiring assembles a lead and specialist agents automatically. The React Flow editor in Dev mode exposes the underlying workflows, tools, context, memory, and skills for technical customization. Public documentation should preserve the employee OS vision, explain it in plain language, and keep development and infrastructure details in a separate technical section.

## Documentation Reference

**Always refer to these documentation files for detailed guides:**

| Document | Description |
|----------|-------------|
| **[Browser AI Agent deployment](./docs-internal/browser_agent_deployment.md)** | Shared creation recipe, local/distributed prerequisites, PostgreSQL offline transfer, exclusive browser ownership, owner recovery, direct task history and release gates. Browser effects route to owner queues even with worker pools disabled. Direct tasks use the existing native agent/Workspace path and remain separate from deployment Start/Stop/Resume. |
| **[1Password credentials](./docs-internal/onepassword_credentials.md)** | CLI-first approved API/login bindings, pinned executable verification, desktop/service-account authorization, metadata-only catalogues, Activity-local resolution, protected CDP login and the local-only OAuth/CLI compatibility boundary. No resolved values in models, Temporal inputs, viewer captures or task history. |
| **[Frontend Architecture](./docs-internal/frontend_architecture.md)** | Current frontend stack (React 19 + Vite + Tailwind v4 + shadcn/ui + Radix + RHF/zod + TanStack Query + Zustand). Tokens, primitives, forms, credentials exemplar, ownership boundary, `uiHints` catalogue. Brand: the Open Council mark and the outlined Newsreader wordmark are SVG paths in `client/src/components/brand/geometry.ts`, drawn in the text colour by `brand/Logo.tsx`; the favicon (`client/public/opencompany-icon.svg`), the desktop icon (`desktop/build/icon.svg`) and the icon-only logo SVGs in `media/brand/` embed its `MARK_PATH` verbatim (`brand/__tests__/appIcons.test.ts`), so change the mark there, paste the new path into each, and render the square PNGs beside those SVGs again (`media/brand/README.md` says how). |
| **[UI Migration Plan](./docs-internal/ARCHIVE/ui_migration_plan.md)** | *Archived.* antd → shadcn/ui migration completion log (Waves 1–10). Current state lives in [Frontend Architecture](./docs-internal/frontend_architecture.md) and [Theme System](./docs-internal/theme_system.md). |
| **[Temporal Cleanup & Resilience Plan (Waves 15-18)](./docs-internal/ARCHIVE/TEMPORAL_CLEANUP_AND_RESILIENCE_PLAN.md)** | *Archived — all four waves shipped.* 15 dead-code retirement (Redis-Streams branch in event_waiter, APScheduler cron stack, orphaned `connection_status` factory), 16 per-queue task routing (`TemporalWorkerPool`), 17 resilience hardening (cron `catchup_window`, SDK interceptors, `DEPLOYMENT_MODE`, periodic heartbeats; the one-shot LLM-step retry it records was later reverted to unlimited-with-backoff), 18 worker performance tuning. Current settings live in [Temporal Architecture](./docs-internal/TEMPORAL_ARCHITECTURE.md). |
| **[Node Allowlist](./docs-internal/node_allowlist.md)** | Single-config UI visibility — `server/config/node_allowlist.json` controls which nodes / credential categories / skill folders show in the UI. Five lists with two enforcement tiers (mode-gated allowlist + absolute blocklist). `useNodeAllowlist` hook exposes `isVisible` / `isBlocked` / `isAllowed` / `isCredentialCategoryDisabled` / `isSkillFolderDisabled`. Adding a new disabled domain = single JSON edit, no code change. |
| **[Normal Mode (Home)](./docs-internal/normal_mode.md)** | The owner-facing landing screen: describe a job, an LLM on the server drafts a setup screen (a JSON UI spec the client parses, normalises and renders from a fixed catalogue), and Hire builds, validates, saves and starts one workflow per AI employee (`services/employees/`, graph built from `config/employee_apps.json` plus the owner's skill library and a Canvas node, and checked against `node_allowlist.json` `enabled_nodes`). Covers the screen switch (`app/`, `shellMode` / `ui_shell_mode`, `VITE_NORMAL_MODE=false` kill switch, the same two themes and theme button as the editor), the employee page (the shared chat, `features/chat`, with the employee named in the header beside New conversation; Stop is in the Workspace's row), deleting an employee from the team sidebar (stopped through Reset first, then gone from every tab), the live Ask first rule (`workflow_rules`, the chat's Ask first chip via `set_ask_first`; app replies wait in `approvalGate`, sending tool calls are held as drafts by `services/approvals/tool_calls.py` and sent after a 5 s Undo window by `ApprovedToolCallWorkflow`; cards in the chat; builder v3 gates every app reply, attaches every app tool and gives the talk agent `chatUi` and the registry's `talk_send` tools, with instructions identical whatever the rule; older employees are upgraded on Apply / Turn on Talk / Start by `services/employees/upgrade.py`; in a generation that waits for the owner (RFC-0007, `approval_wait_version` 1, which Start does not write yet) Ask first becomes a mode over a class table (`services/approvals/classes.py`) minus always-allow rules (`allows.py`), the agent answering a chat run waits in its turn on `ApprovalWorkflow` and runs the allowed call itself, and the gate no longer holds Stop), run records for "done today" (`machina-run-record-v1`), automatic-pause reasons, Settings (Profile fields and switches, among them Generated UI, off by default and also in Dev's Settings panel: with it off a hire fills a fixed form and the server draws the setup card, `services/employees/setup_form.py`; usage-only Billing via `get_employee_usage`; Skills, the user-skills library each new hire copies onto a Skills node; consumer Connectors with `publisher` / `verified`; Plugins, the starter bundles in `hire/starters.json`; the last three share one catalog page), the Workspace dock (one header-high row: pill tabs, the employee's status dot (`workspaceStatus`; Live in red), their main action (a round Stop or Resume, else a small labelled Start, Start again, Connect or Retry reset), Expand and Close; an employee's Canvas board, named by the summary's `canvas_node_id`; a live Browser tab under one row of page controls, shared with Dev mode; Mobile shows the managed local Android phone; a floating status pill with the latest step from the step log `services/workspace_steps.py`, read with `workspace_steps_list` and again on `workspace_step`; Take over, in the Browser row and the phone's device bar, which takes the screen and stops the employee until Hand back, in the "You're in control" banner the status pill becomes meanwhile, and shows again on a screen that let go of the owner; Dev mode shows the same dock for the open workflow: both screens mount one shell, `components/workspace/WorkspaceDockShell` (sized by `components/workspace/dockSize`), around one body, `features/home/workspace/WorkspaceView`, with the shared `WorkspaceTabs` / `FullView` / `BrowserWorkspace` / `MobileWorkspace`; the editor's `CanvasDock` only adds its own (nodes from the canvas, the Canvas node picker, file previews, and Dev mode's extras through the views' `dev` prop), and its row has no main action because the toolbar has Start, Stop and Resume), the "Web browser" app (attached whole, each call read-only while the owner asks first through its approval spec's `restrict_while_asking`; while the agent waits in `request_user` the summary's `browser_request` shows Needs you and Help in browser), Talk on the employee page (a thread over the chat session whose id is the workflow id, rows written through `services/chat_thread.py` and, for the owner's messages and their chat runs, `services/chat/ledger.py` (see [Chat Protocol](./docs-internal/chat_protocol.md)); the talk line is chatTrigger → an agent → `chatReply` "Reply in Chat": a chat hire's own agent is its talk agent, other hires get a "Talk with {name}" agent sharing the worker's tools and skills with its own Context, and schedule hires also "Post to Talk"), Turn on Talk (for talk `off`) and Apply (while the summary's `pending_changes` is true) via `enable_employee_talk` / `apply_employee_changes`, each a restart on the saved graph through `services/deployment/restart.py`, self-extension from Talk through the Agent Builder within the Hire rule (`services/employees/policy.py`), the 3D orb, the wire contract and the `employee_lifecycle` / `approval_lifecycle` / `chat.updated` / `workspace_step` broadcasts. |
| **[Chat Protocol](./docs-internal/chat_protocol.md)** | The wire contract between the chat runtime (`server/services/chat/`) and the shared chat UI: AG-UI's event model on the existing WebSocket, snake_case, in the CloudEvents envelope. A message a deployed chat trigger will answer starts a **chat run** admitted with it in one reserved write transaction (`Database.reserved_session`, SQLite `BEGIN IMMEDIATE`): one live run per session (the lane, also a partial unique index; a second send answers `run_in_progress`), idempotent `client_message_id`, claimed and finished by the MachinaWorkflow that answers it behind `machina-chat-run-v1` (the run id is trusted only from an event whose source is `opencompany://services/chat`; `run_scope` rides every node context; only the claimant finishes), the reply saved as `a_<run id>` by `chatReply`, every message appended after the session's active leaf (`chat_threads`) so concurrent writes never fork the thread. `chat_run_event` frames reach only sockets that passed `chat_subscribe`'s owner check (`services/chat/hub.py`: per-run `seq`, `hub_epoch`, bounded per-socket queues with a resync), never the status broadcast; a standalone worker sends its run events, `chat.updated` and `approval_lifecycle` to the backend over `/ws/internal` (`services/chat/relay.py`, `chat_run_publish`, ordered, dropped events resynced). A watchdog (`services/chat/watchdog.py`, started by `main.py`) ends runs nothing will finish (`not_delivered`, `timed_out`, `interrupted`). The agent wired into Reply in Chat (`answers_chat_run`) streams its answer as `text.*` events (`services/chat/stream.py`, activity input only, no workflow patch), its tool calls show as steps (`services/chat/steps.py`; label from the plugin's `chat_step`), and `stop_chat_run` stops a run cooperatively: its LLM step polls the run and returns what was shown, unstarted tools are skipped, the reply is saved `stopped`, and the watchdog cancels the workflow of a run still stopping after `runs.stop_grace_s`, and of one running past `runs.max_running_s` (`timed_out`). Generated UI: one owner-wide switch, `UserSettings.generated_ui` (off by default), read by `services/genui/switch.py` when a run starts, when Reply in Chat saves the reply and on each setup request; while it is off, `collect_agent_connections` and `collect_teammate_connections` leave `chatUi` out of every agent's and teammate's tools, the guide asks for no follow-ups and the hire screen is a fixed form. The `show_ui` tool (`chatUi`; its description written by json-render's `catalog.prompt()`, `bun run genui:prompts`) is checked against `config/chat_genui_catalog.json` (`services/genui/spec.py`; refused with every reason so the model retries), saved as a run part, streamed as `activity.*` RFC 6902 patches and sealed into the reply (`services/chat/parts.py`); `chat_ui_state` keeps what the owner set, and a button press comes back as `send_chat_message{ui_event}` -> a `[ui-event]` turn. The answering agent's system prompt ends with a fixed guide (`services/chat/guide.py`), what the owner set reaches its next turn as a `[ui-state]` note (`chat_notes`, `services/chat/notes.py`: claimed by the payload, told when the run answered), a trailing `<followups>` block becomes `parts.followups` (kept only while Generated UI is on), and a `chat_sources` tool's results are numbered per conversation for `[n]` citations (`services/chat/sources.py`). Approvals (`services/approvals/`, its own section): a plugin's `approval` spec marks a tool call that sends; while the workflow asks first it is held as a `tool_call` draft, shown as a card on the reply that made it, sent once after the Undo window by `ApprovedToolCallWorkflow`, with the outcome told to the employee as an `[update]` note; in a generation that waits for the owner (RFC-0007, not yet written by Start) the mode and the call's class decide what asks, the agent answering the run waits in its turn for Allow or Decline (`services/approvals/waiting.py`; a message sent meanwhile declines the call and answers `declined`), and the watchdog does not time the wait out. Branches (`services/chat/branches.py`): `get_chat_messages` returns the path to the thread's active leaf, each message with its versions (`siblings`), `editable` and the owner's rating; `edit_chat_message` / `regenerate_chat_reply` / `switch_chat_branch` move the path in one reserved transaction under the conversation store's lock, taking each agent's stored conversation back to the cursor its run recorded at `prepare_payload` (`chat_runs.context_cursors`) or restoring the branch's kept copy (`chat_branch_snapshots`), refused on `revision_conflict`, `run_in_progress`, `cannot_rewind`, `branch_unavailable` or `older_generation`; `set_chat_feedback` keeps a rating and tells the next turn (`[feedback]` note). Attachments (`services/chat/attachments.py`): the chat uploads files under `uploads/` and sends only their paths, the server rebuilds each reference from the file (refusing anything else, at most six), the answering agent reads an `[attachments]` line and images ride the opening user message for a provider with `vision.user_images`; `options.web: false` drops the `search` tools for that run; Home's model picker (`services/chat/choice.py`, `get_chat_models`) sends `options.model` / `options.effort`, resolved at admission (Auto = the owner's default model; refused with `model_unavailable`, never swapped) and applied only to the answering agent, effort only to a provider's `effort_models`; `get_chat_context` gives slash commands (generic plus the apps' `commands`) and capabilities; dictation is the speech plugin's `dictation_status` / `transcribe_audio`. Lists every event, handler, part and error code (among them `engine_unavailable`: with Temporal not connected a workflow's chat saves nothing, since nothing could deliver it). |
| **[Theme System](./docs-internal/theme_system.md)** | Two themes, `light` and `dark` (the design handoff's ten skins are gone: nine were removed on 2026-10-12 and the last, `atomic`, on 2026-11-09), driven by `<html data-theme>` + per-theme CSS files in `client/src/themes/`. Home, the editor and the sign-in screen all show the chosen theme, and one theme button ([components/ui/ThemeButton.tsx](./client/src/components/ui/ThemeButton.tsx)) in the Home header, the editor's toolbar and the sign-in screen toggles Light and Dark (`cycleTheme`; every theme name comes from `THEME_LABEL` in `ThemeContext.tsx`). Token taxonomy (surface / fg / border / accent / typography / motion) in **hex + `color-mix()`**; `@theme inline` bridge maps `--color-X: var(--X)` (no `hsl()` wrapper); per-theme files own the colour hex, base.css owns the shared `--tint-*` alpha scale; action role tokens (incl. `-ink` readable text), **per-theme `--code-*` syntax tokens** (tier 6 — the code editor, console/output JSON viewers, and chat code blocks paint in each theme's own palette; replaced the global dracula `--prism-*` block + dead `getPrismTokenCSS`; the `OutputPanel` JSON viewer reads the same vars), the `--pulse-keyframe` animation system + `.opencompany-*` helpers in `animations.css`, per-component decoration hooks (`.canvas-host`, `.toolbar`, `.sq-node`, …; kept as the theme contract, mostly unstyled by light.css and dark.css), **canvas-node visual contract via `--node-color` CSS custom property** (no inline `background` / `border` on node components — base.css + per-theme CSS owns visuals; `NodeStyle` helper type at [types/NodeTypes.ts](./client/src/types/NodeTypes.ts) makes the inline custom-prop typecheck-clean), **`--node-pulse-color` separate from `--node-color`** so executing-node glow uses each theme's highest-contrast accent regardless of plugin accent, **`data-page-hidden` animation pause** (set by `app/usePageActivitySync.ts`, mounted in `App.tsx` above the sign-in gate so it covers every screen, while the page is hidden per the Page Visibility API, never merely because the window lost focus; base.css declares `html[data-page-hidden] *, *::before, *::after { animation-play-state: paused !important }` to prevent compositor stall on tab return), **per-theme canvas-grid + custom cursors** via `--canvas-grid` / `--cursor-default` slots, **parameter panel migrated to Tailwind tokens** (no `useAppTheme()` reads; section headers carry the display-typography triplet; raw `<Button>` swapped to `<ActionButton intent>`), scrollbars from the global token rules, 9-event WebAudio sound system (2 packs — parchment, terminal — via `--sound-pack` token + `useSound()` hook + global hover delegate + sonner toast monkey-patch + `withSound()` HOC + `Sounds.unlock()` gesture-unlock for AudioContext autoplay-policy compliance), `@media (prefers-reduced-motion: reduce)` accessibility, 30 ms throttle on `type` / `hover`, migration recipe, anti-patterns. Read this before adding a new theme, migrating a component to the new contract, or adding a canvas-node component. |
| **[Design System Bundle](./docs-internal/design-system/IMPLEMENTATION.md)** | Canonical, vendored design-system reference (extracted from this repo; see its `IMPLEMENTATION.md` for the contract). Tokens are **hex + `color-mix()`** (`tokens/{colors,typography,spacing,motion,fonts,base}.css`) — the format the live codebase standardizes on. Includes 34 reference components (`components/`, inline-style reference only — recreate in Tailwind/shadcn idioms), full-app UI kit (`ui_kits/opencompany/index.html`), theme docs (`guidelines/THEMES.md`), **the merged handoff brief (`HANDOFF.md` — glow-ladder traps, step-edge contract, toolbar layout traps, canvas-layer asymmetries, plus the recorded product amendments)**, and **the panel×theme fidelity target (`reference-mockup/Panel Theme Matrix.dc.html` — 17 panels × 2 themes, open in a browser)**. `reference/themes/` no longer vendors CSS snapshots — it points at the authoritative [client/src/themes/](./client/src/themes/). Copy token values verbatim; do not re-derive by eye. Pairs with [Theme System](./docs-internal/theme_system.md). |
| **[Schema Source of Truth RFC](./docs-internal/ARCHIVE/schema_source_of_truth_rfc.md)** | *Archived — shipped.* Backend is SSOT for node schemas, visual metadata, handlers, palette metadata, icons; `VITE_NODESPEC_BACKEND` defaults on and the frontend node definitions are gone. Plugin pattern: one `BaseNode` subclass in `server/nodes/<group>/<plugin>/__init__.py`. Wire format: `asset:<key>` / `<lib>:<brand>` / URL / emoji. Endpoint: `/api/schemas/nodes/{type}/spec.json`. Current reference: [Plugin System](./docs-internal/plugin_system.md). |
| **[Plugin System (Wave 11)](./docs-internal/plugin_system.md)** | Class-based plugin-first architecture. `BaseNode` / `ActionNode` / `TriggerNode` / `ToolNode` + `@Operation` decorator. Pydantic `Params`/`Output`. Declarative `Routing` DSL + `Connection` facade (Nango pattern; `request()` supports `json` / `data` / `files` — the last for multipart uploads, threaded through the auth-retry rebuild). `Credential` subclasses live in each node folder's `_credentials.py` (or inline for single-use); live count via `len(services.plugin.credential.CREDENTIAL_REGISTRY)` — do not hand-maintain a number here. `TaskQueue` constants route to Temporal worker pools. Plugins live across 9 queues (live plugin count via `len(services.node_registry.NODE_METADATA)`; a bare `__init__.py` glob overcounts by also matching group packages); handler bodies fully inlined (`services/handlers/` shrank 12.8K → 1.1K LOC across 16 → 4 files; only cross-cutting orchestration remains: `tools.py` AI-tool dispatch + agent delegation, `triggers.py` generic trigger-node handler, `todo.py` writeTodos shim — `google_auth.py` was retired into `nodes/google/_auth_helper.py`). **Wave 11.H added "self-contained plugin folders"** — generic registries (live list via `grep -rn '^def register_' server/services server/core`; the plugin-facing ones are `ws_handler_registry.{register_ws_handlers,register_router,register_option_loader,register_oauth_callback_path,register_public_ingress_path}` (the last for a route a provider calls without a session cookie, which must authenticate each request itself), `event_waiter.{register_filter_builder,register_trigger_precheck}`, `status_broadcaster.register_service_refresh`, `node_output_schemas.register_output_schema`, `edge_walker.{register_agent_context_builder,register_master_skill_expander}`, `register_webhook_source`, `register_canary_trigger_type`, `register_poll_coroutine_factory`, `register_social_send_handler`, `register_shutdown_hook`, `register_service_factory`, `register_log_source_tag`, `register_conversation_listener`) so rich plugins like telegram own their entire surface area without core-services edits. |
| **[Nodes Cookbook](./server/nodes/README.md)** | 5-minute recipe + folder map + shared helpers (`_base.py` / `_inline.py` per domain) + shared credentials + **canonical folder-per-plugin shape (telegram is the reference implementation)** + contract invariants + common pitfalls. Lives next to the plugin files. |
| **[Node Creation Guide](./docs-internal/node_creation.md)** | Canonical plugin recipe — one self-contained folder per plugin under `server/nodes/<group>/<plugin>/`, rooted at `__init__.py`. Multi-file split (`_service.py` / `_handlers.py` / etc.) when the plugin owns long-lived state. Zero frontend edits, zero core-services edits. Auto-registers via `BaseNode.__init_subclass__` + the generic `register_*` hooks (see the Plugin System row). Covers tool nodes, dual-purpose nodes (workflow + AI tool), and specialized agents as variations of the same recipe. |
| **[Agent Architecture](./docs-internal/agent_architecture.md)** | How AI Agent and Chat Agent discover skills/tools, inject them into LLM prompts, and execute through the provider-neutral `run_native_agent_loop` |
| **[Agent Delegation](./docs-internal/agent_delegation.md)** | How memory, parameters, and execution context flow when one AI agent delegates work to another agent connected as a tool |
| **[Agent Teams](./docs-internal/agent_teams.md)** | Claude SDK Agent Teams pattern - AI Employee and Orchestrator nodes with input-teammates handle for multi-agent coordination |
| **[Agent Context Flow](./docs-internal/agent_context_flow.md)** | Normative continuity reference — how a conversation survives firings, delegations, and rollovers on both paths. Plain conversation store: one `agent_conversations` row per `(workflow_id, generation, agent_node_id)` → messages JSON, loaded at run start and saved per turn (LangGraph thread_id / OpenAI conversation_id pattern; the journal/thread/epoch machinery is gone). Seeding precedence (carried rollover transcript > stored conversation > bare build, 1 MB guards), LOUD load failures (`ConversationLoadFailed` / `ConversationTooLarge`) vs best-effort saves, transcript size bounds (per-call tool-result cap, byte budget that clears old results, `context_pressure_version` replay contract), the specialized-provider bridge, and the August 2026 "lead forgot its plan" regression. Read before touching `services/agent_context/`, `nodes/context/`, `services/cli_agent/context_bridge.py`, `services/tool_output.py`, `agent.prepare_payload`, or the team-completion event chain. |
| **[Memory Compaction](./docs-internal/memory_compaction.md)** | Native usage aggregation plus shared client-side summarization through `ChatUnifier` for memory-connected agents. Threshold = `Settings.compaction_ratio` (env `COMPACTION_RATIO`, default 0.8) × model context length; see the document for the per-session override limitation on Temporal runs. |
| **[Pricing Service](./docs-internal/pricing_service.md)** | Centralized cost tracking for LLM tokens and API services (Twitter, Google Maps) with HTTPX event hooks |
| **[Proxy Service](./docs-internal/proxy_service.md)** | Residential proxy provider management with template-based URL formatting, health scoring, and transparent HTTP node injection |
| **[Email Service](./docs-internal/email_service.md)** | IMAP/SMTP integration via Himalaya CLI with EmailService orchestrator, provider presets, custom credential fallback, and polling triggers |
| **[Browser](./docs-internal/browser.md)** | Current `browser` node: installed Chrome/Edge/Chromium in a dedicated profile rendered inside the workspace by default (`BROWSER_RUNTIME=system`, `BROWSER_HEADLESS=true`) and pinned browser-use CLI. Chrome for Testing requires explicit `BROWSER_RUNTIME=testing`. System mode retains the native user agent and OS password-store/keychain integration; basic/mock keychain flags are restricted to testing mode. Includes persistent employee/shared profiles (the Credentials / Connectors "Web browser" panel adds, deletes and imports logins from a session file; the node's profile dropdown lists only the caller's profiles, via `current_load_options_principal()`), session leases, agent/user handoff, network policy, screenshots as workspace FileRefs and authenticated `/ws/browser` live viewing. Plugin-owned installation is lazy (`_install_chrome.py`, `_install_bu.py`); importing or shutting down the plugin never installs it. Replaces the former agent-browser and separate `browserHarness` implementations. |
| **[Browser Workspace](./docs-internal/browser_workspace.md)** | Home and Dev show one Workspace dock (`WorkspaceDockShell` around `WorkspaceView`, with `WorkspaceTabs` and `BrowserWorkspace`): Browser / Canvas / Mobile, capability-based discovery, binary JPEG frames, bounded frame credit and decoding, Take over / Hand back (one row of page controls in both modes; the dock's Take over and Hand back also stop and resume the employee; the address box, Full view and browser tasks are Dev mode's extras, the view's `dev` prop), the agent's cursor (`agent_action`: where each click, hover, type or select happened, beside the agent's result and never during a protected login), visibility cleanup and payload-free opt-in diagnostics. The Browser node's parameter panel shows the same viewer, and the Dev dock opens on its Browser tab when an agent in the open workflow asks for help (`browser_updated`). Mobile shows the managed local Android phone. |
| **[MCP Connectors](./docs-internal/mcp_connectors.md)** | Custom connectors: the owner adds an outside MCP server from Connectors → Add, or the MCP server card in the Custom category (`mcp`, kind `mcpAdd`, never connected; its page `panels/McpAddPanel.tsx` is the same form) (URL, name, sign-in: none, bearer, one header, or OAuth), and OpenCompany is its MCP *client* (`server/nodes/mcp/`, official `mcp` SDK; unrelated to the CLI agents' `/mcp/ide` server). The server reads the tools before keeping anything: streamable HTTP, then SSE; our own httpx client with redirects off; host and resolved addresses of every request (an OAuth sign-in's to its authorization server too) checked against `services/netpolicy.py` (shared with the Browser node: never cloud metadata or OpenCompany's own ports, plain http only on the owner's network), within `DISCOVER_S` (45 s, under the app's 60 s probe wait). Kept as `mcp:<slug>` (sign-in, encrypted; plain `model_params._mcp`: tools as accepted, per-tool `{enabled, ask}`, a waiting refresh) and `mcp:<slug>_proxy` (URL). A tool no model could call is kept with the reason, never renamed. Each connector is its own card through the generic `Credential.catalogue_entries` hook and the `_mcp_connector` template. Commands `mcp_connector_add` / `_sign_in` / `_test` / `_refresh` / `_review` / `_set_tool` / `_remove`; Refresh holds every change for Accept. Client: `AddConnectorForm.tsx` and `panels/McpConnectorPanel.tsx`. The `mcpConnector` node on an agent's Tools gives one tool per tool that is on (`ToolNode.tool_bindings`, named `<slug>__<tool>`, run with its binding's locked `mcp_tool` / `mcp_ask` / labels; arguments checked with jsonschema, a `$ref` followed only inside its schema: a tool whose inputs refer to another document is unusable, and a call goes as a plain `tools/call`, so the SDK never checks a result against an output schema the server sends; Ask first per tool through the node's `approval` spec); CLI agents leave it out and RLM refuses it. OAuth (`_oauth.py`) is the SDK's `OAuthClientProvider`: Add or Sign in again answers `sign_in_url`, the sign-in finishes in the background through `/api/mcp/oauth/callback`, tokens go to the OAuth token store under `mcp:<slug>` with their expiry (`store_oauth_tokens(..., expiry=)`), and each connection restores the expiry (read uncached with `get_stored_oauth_tokens`) and the endpoints before first use, so the SDK refreshes instead of opening a sign-in; a token issued without an expiry is renewed once the server refuses it (`_oauth.connect`); a refused refresh says Sign in again. Each connector is also an app a hire can use: `connector_apps`, registered with `services/employees/apps.register_app_source`, is read by `Connections.apps` with `employee_apps.json`'s apps (a saved one counts as connected), `mcpConnector` is on the hire allowlist, and the employee's card reads the connector from the node's `app_field` (`mcp_connector`). |
| **[WhatsApp Business Service](./docs-internal/whatsapp_business_service.md)** | Official Meta Cloud API plugin in [`server/nodes/whatsapp_business/`](./server/nodes/whatsapp_business/) — **distinct from `nodes/whatsapp/`**, which is a personal account over an unofficial Go bridge; they share no node type, credential or palette group. Four nodes: `whatsappBusinessSend` (10 ops — Meta models message type as a *field* on one `/messages` endpoint, so text / media / template / interactive are operations, not nodes), `whatsappBusinessMedia` (the one genuinely separate endpoint family), and the `Receive` / `Status` triggers (kept separate because a canary trigger registers exactly one CloudEvents type). `phone_number_id` is deliberately **absent from Params** and comes from the credential only, because it selects the sending identity — `server_controlled_fields` would keep a model-set value out on every tool path, but an ActionNode's tool schema is its whole `Params` (`ToolInput` is ToolNode-only), so a declared field would still appear in the schema the model sees. Webhook via `register_webhook_source` on the shared catch-all, `X-Hub-Signature-256` HMAC failing closed, composite `wamid:status` dedup ids so `sent→delivered→read` don't collapse. |
| **[Stripe Service](./docs-internal/stripe_service.md)** | Stripe CLI integration — `stripeAction` (CLI pass-through) + `stripeReceive` (signed-webhook trigger, on a canvas Run and deployed: every Stripe event travels under one CloudEvents type, `com.opencompany.stripe.event.received`, with the Stripe type in `subject` and `data.event_type`, where the node's own `build_filter` reads it; the source shapes the payload once, wakes the in-process waiter through `WebhookSource.handle` and reaches deployed listeners through `dispatch.emit`; deploying starts `stripe listen` through the generic `TriggerNode.prepare_deployment` hook, at Start and at the boot re-arm). Reference plugin for the Wave 12 event framework AND the **CLI-managed-auth pattern**: browser OAuth via `stripe login --non-interactive` + `--complete <url>` (URL extracted from `next_step` shell command via `shlex.split`) + auto-installed binary (`ensure_stripe_cli` from GitHub releases) + marker-token reuse of `auth_service.store_oauth_tokens` + CloudEvents-shaped `broadcast_credential_event("credential.oauth.connected" \| ".disconnected", provider=...)` — zero per-provider hardcoding in the frontend. Daemon stdout/stderr ingested via `ProcessService`'s `line_handler` callback (no log-file tailing); credential gate routed through `await self.has_credential()` so non-api-key auth (`is_logged_in()`) plugs in via subclass override. |
| **[Dodo Payments Service](./docs-internal/dodo_payments_service.md)** | Dodo Payments integration over the official SDK (`dodopayments`, Stainless-generated, capped `<2`) — `dodoPaymentsAction` (list / get payments, subscriptions, customers, products; list refunds and disputes; `create_checkout_session` (returns `checkout_url`), `create_customer`, `create_customer_portal_link` (`send_email` off), `create_refund` (whole payment, one item, or part of it), `cancel_subscription` (`subscriptions.update`: the SDK has no cancel method), `change_subscription_plan`; `call` for any SDK method by its dotted name and `list_operations`; AI tool `dodo_payments_action`; `dodoPaymentsEndpoints` loader) + `dodoPaymentsReceive` (Standard Webhooks trigger at `/webhook/dodo-payments`). **A Dodo key works only in its own mode and carries no marker**: `DodoPaymentsCredential` (`ApiKeyCredential`, id `dodo_payments`) validates with `GET /brands` in test mode, then live mode, and its `validate` override saves the mode that accepted the key (`dodo_payments_mode`); every call goes to that mode's base URL from the SDK's `ENVIRONMENTS`, key and URL passed explicitly so no `DODO_PAYMENTS_*` variable authenticates or redirects a node. **Writes run with the SDK's retries off** (it retries POSTs and the API takes no idempotency key, so a retried refund could be paid twice); a write that times out or meets a 5xx says its outcome is unknown. **Nothing vendored**: `_sdk.endpoint_index()` reflects the installed SDK (`cached_property` resources, routes from the generated `self._get(path_template(...` source, parameters from signatures minus the SDK's per-call keywords, which `call` refuses since `extra_headers` could replace auth); the `webhooks` resource is left out (signing secrets have no use in an agent's hands); a test fails if any other method's route is missed. Lists are one page each (the SDK's auto-pagination asks for page 2 after page 0). Approval per operation: refunds, cancellations, plan changes and a `call` on a non-GET endpoint are held under Ask first (`ApprovalSpec` `operations` + a `when` on the endpoint's verb); reads and checkout links run. The trigger follows Stripe's fixed pattern (one CloudEvents type `com.opencompany.dodo_payments.event.received` with Dodo's type in `subject` / `data.event_type`, `event_type` and `build_filter` on the node, envelope id = `webhook-id` so a retried delivery is a duplicate, `emit` from the source's `handle`, both trigger sets in `constants.py`) but emits with `broadcast=False` (customer details, no client reads them); `StandardWebhooksVerifier` refuses timestamps outside the spec's five-minute window. SDK imported only inside `_sdk.py` (clean-interpreter test); tests run the real SDK under respx. Palette group `payments`; hire app with `side_effects: "money"`. |
| **[Vercel Service](./docs-internal/vercel_service.md)** | Vercel CLI integration — `vercelAction` (deploy / inspect / list + `custom` CLI passthrough; AI tool `vercel`). Second reference for the CLI-managed-auth pattern: the **device-flow variant** (`vercel login` is one blocking process — no two-step `--complete`; direct `create_subprocess_exec` + chunk-based banner read for the code-embedding URL, pumps drain pipes for the process lifetime, success gate = auth.json mtime advance + sniff). Dual auth: optional `vercel_token` field (injected as `VERCEL_TOKEN` env, never argv) OR CLI login with marker tokens. All invocations pin `--global-config <DATA_DIR>/vercel/` (the `CLAUDE_CONFIG_DIR` isolation idiom). `bun add` into the shared packages tree (pinned version, via `core/js_runtime.add_package`; the CLI runs on bun). First-deploy project guard raises `NodeUserError` instead of letting Vercel derive an invalid name from the workspace dir. Shipped with the generic OAuthConnect change: Login gates on **required** fields only, so optional fields never block CLI login. |
| **[GitHub Service](./docs-internal/github_service.md)** | GitHub CLI integration — `githubAction` (repo_clone / pr_create / pr_list / pr_merge / issue_create / issue_list + `custom` passthrough; AI tool `github`). **Strict Stripe pattern: the gh CLI owns its own auth** — no token stored/injected by OpenCompany, no auth pre-flight (gh's own error surfaces), marker OAuth row for the modal badge, fieldless catalogue entry. Login = spawned `gh auth login --hostname github.com --git-protocol https --web` — source-verified: headless takes the `isInteractive=false` branch (no Press-Enter), prints one-time code + `github.com/login/device` URL on stderr; the modal shows the code via the generic `verificationCode` plumbing (`useCredentialPanel` → `OAuthConnect`). `login_env()` strips `GH_TOKEN`/`GITHUB_TOKEN` (login aborts otherwise). Success gate = `gh auth status` exit 0, then best-effort **`gh auth setup-git`** (official bridge — the future git node needs zero auth code). gh binary: **project-local, pooch-driven** (temporal `_install.py` idiom) — pinned release under `package_dir("gh")`, system gh never consulted (auth state still shared: gh config paths are user-level). Palette group `vcs`. |
| **[Cloudflare Service](./docs-internal/cloudflare_service.md)** | Cloudflare CLI integration — `cloudflareAction` (whoami / `search_commands` (`cf cli search`) / zones_list / DNS records list-create-edit-delete / `graphql_query` / `custom` passthrough; AI tool `cloudflare`) wrapping the official `cf` CLI (npm `cf@1.0.0-beta.12`, open beta — "the next version of Wrangler"). **Pin enforced, not just installed once**: `ensure_cf_cli` compares `core.js_runtime.installed_version("cf")` with the pin and re-runs `bun add` on a mismatch (clearing the login marker, since a new version may not see the old login). **Login = gh's device-flow shape**: `cf auth login --force --device --no-browser` prints a URL + one-time code on stderr that the handler relays to the modal (works from any machine; the old fixed-port-8877 loopback flow is `--no-device` only) — single flight (a repeat click returns the same code), install before any login (no invisible codes), never-kill-the-shim (killing bun's `cf.exe` launcher orphans the child), success gate = `cf auth whoami` JSON (`authenticated: true` — cf exits 0 in BOTH auth states). **cf contract traps**: destructive commands without `--force` print "Aborted." and **exit 0** (delete always passes it; `custom` turns it into an error); no `--account-id` flag on most commands (account = `CLOUDFLARE_ACCOUNT_ID`, from the `account_id` param — several accounts fail non-interactively without it); cf resolves a domain `--zone` only in the first page of the selected account, so the node resolves domains itself via `zones list --name`; no output-format flag (unknown flags rejected); cf reads `.env` files from its working directory and fails under bun on a `cloudflare.config.ts` found above it, so typed ops run from a neutral `<DATA_DIR>/cloudflare` (`custom` from the workspace) with `CF_DELEGATION=1` (otherwise bun lets cf hand off to another cached cf copy) and telemetry off. The optional `apiKey` field (an API token) rides `CLOUDFLARE_API_TOKEN` and beats the login; cf does NOT accept a Global API Key, so a `cfk_` key (with `cloudflare_email`) serves only `graphql_query`, which POSTs the official `client/v4/graphql` endpoint directly (no cf GraphQL command; the cf login's token never leaves the CLI) — needs `Account > Account Analytics > Read`. System cf never consulted. Palette group `deployment`. |
| **[Google Cloud Service](./docs-internal/gcloud_service.md)** | Google Cloud CLI integration — `gcloudAction` (auth/config/projects + set_project / Compute Engine instances list-start-stop-describe / Cloud Run deploy-list-describe / Cloud Storage ls-cp-rm / `custom` passthrough; AI tool `gcloud`) wrapping the official gcloud CLI (pinned versioned archive via pooch into `package_dir("gcloud")` — NOT npm; the Windows bundled-python zip keeps the legacy `google-cloud-sdk-` filename prefix, the `-cli-` spelling 404s, locked by test). CLI-opens-the-browser login variant (the pre-1.0 Cloudflare flow; gcloud picks a random loopback port — no fixed-port hazard) + the gh marker-token pattern; success gate = `gcloud auth list --filter=status:ACTIVE --format=json` non-empty (exit codes never trusted). **Config isolation divergence from gh**: every invocation pins `CLOUDSDK_CONFIG=<DATA_DIR>/gcloud/`, so terminal logins against the operator's global gcloud are deliberately NOT visible to the node. ADC note: node login mints gcloud USER creds only (CLI commands never read ADC); client-library code gets ADC via the `custom` op `auth application-default login` (lands isolated in the pinned dir). Cold install ~100 MB → login answers `pending: True` within the 22 s WS budget. Provider id `gcloud` is orthogonal to the Workspace `google` OAuth2 provider. Palette group `deployment`. |
| **[AWS Service](./docs-internal/aws_service.md)** | AWS integration over the official SDK — `awsAction` (whoami / EC2 instances list-describe-start-stop / S3 list-upload-download-delete / `call` for any operation of any AWS service by its boto3 method or API name / `list_operations` from the SDK's bundled models, with no credentials or network; AI tool `aws_action`; `awsServices` / `awsOperations` dropdown loaders). **The SDK, not the AWS CLI**: CLI v2 ships as an MSI / `.pkg` with no portable archive to pin on Windows or macOS, and its browser login (`aws login`) cannot run on a hosted server. `boto3` is a main server dependency imported only inside `nodes/aws/_sdk.py` (a clean-interpreter test locks that importing the plugin never loads it). Auth = an IAM access key pair in the encrypted store: `AwsCredential` (`ApiKeyCredential`, id `aws`) keeps the secret under the provider id plus `extra_fields` `aws_access_key_id` / `aws_region` (the Cloudflare companion-field pattern); `resolve()` also requires the ID, and the modal probe signs `sts:GetCallerIdentity`, so the ID must be saved before the secret validates. **No ambient credentials**: the pair is passed to every client explicitly, so environment variables, `~/.aws` and an instance profile on the server never authenticate a node. One cached, credential-free botocore session; client creation runs under a lock (botocore sessions are not thread-safe; ~5 ms per client once a service's model is loaded) and calls run in `asyncio.to_thread` with 10 s / 60 s timeouts and `standard` retries. `call` resolves the operation (with `difflib` suggestions) before any credential use and returns one page; responses are shaped the way the CLI prints them (no `ResponseMetadata`, ISO timestamps, base64 blobs, streaming bodies inline up to 1 MB). S3 transfers go through `coerce_file_param` / `write_media` and stop at `MEDIA_MAX_READ_BYTES` (25 MB); AWS errors become `NodeUserError` with code, message, request id and a hint. Tests run real botocore clients under `Stubber`. Unrelated to `company deploy --provider aws`, which drives the operator's own aws CLI. Palette group `deployment`. |
| **[TikHub Service](./docs-internal/tikhub_service.md)** | TikHub social-scraping integration — `tikhubAction` (`call` / `list_endpoints` / `fetch_url` / `account`; AI tool `tikhub_action`; palette group `scraper`, visible in normal mode) over the **official `tikhub` SDK** (`tikhub>=2.1.2,<3`, auto-generated from TikHub's `openapi.json`; ~1,100 methods across TikTok / Douyin / Instagram / YouTube / Twitter / Xiaohongshu / Bilibili / Kuaishou / Weibo / Reddit / Threads / LinkedIn / Zhihu). **The node is the "flattened CLI"**: one `call` op addressed by the SDK's own `resource.method` id (`douyin_web.fetch_one_video`, also `resource/method` or the `/api/v1/...` path) with **reflective `getattr` dispatch** and `inspect.signature(...).bind` pre-flight so unknown kwargs fail before any paid request; **nothing is vendored** — `endpoint_index()` introspects the installed SDK at runtime (resources = `AsyncResource` instance attrs, params = keyword-only signature, route from the docstring's ``GET /api/v1/...`` literal), cached per process, and feeds both `list_endpoints` and the `platform`-dependent dynamic `endpoint` dropdown (`tikhubEndpoints` loader; `all` returns no options, the LLM path stays free text). `tikhub` is imported lazily inside `_sdk.py` only (plugin imports with `sys.modules["tikhub"] = None`). `TikHubCredential` = bearer `ApiKeyCredential` with a declarative httpx probe against `/api/v1/tikhub/user/get_user_info` (never the unauthenticated health route); runtime key via `TikHubCredential.resolve` so a missing key yields the structured `PermissionDeniedError` envelope. SDK `TikHubError` subclasses map to `NodeUserError` (401 key rejected -> Credentials; 403 scope-or-balance -> `account`; 429 + `retry_after`; 400/422 with TikHub `detail`; 404; 5xx upstream after the SDK's own retries) and HTTP-200-with-in-body-`code >= 400` is raised too. **Flat `$0.001`/request tracking** (`pricing.json: api.tikhub` + `operation_map.tikhub`, `track_tikhub_usage` -> `api_usage_metrics`, only after a 2xx, never for `list_endpoints`). SDK drift is guarded by two contract tests (every `PLATFORMS` value matches a live resource; every backticked id in the skill resolves). |
| **[Discord Service](./docs-internal/discord_service.md)** | Discord bot integration — `discordSend` / `discordAction` (AI tools) + `discordReceive` / `discordInteraction` triggers. **discord.py owns the gateway, REST is plugin-owned** (nodes must work with no gateway, `discordAction` needs arbitrary routes, and the invalid-request guard is process-wide across accounts). **Multi-account rides the existing `session_id` credential column** — `_accounts.py` is the only file that knows `account_id -> "discord:<app_id>"`, `"default"` is the row the modal already writes, and `credential_customer_id` is deliberately NOT used (it is per-execution-context tenancy, so two nodes in one workflow could never target different bots). **`account_id` is in `server_controlled_fields`, which the framework enforces on every tool path** (`BaseNode.execute_as_tool` strips it in process, `NodeExecutor` restores the saved value on Temporal); `AccountScopedNode` strips it again as defence in depth, and only runs in process, since the per-type activity calls `execute` for an ActionNode. Attachments are metadata on the trigger and downloaded by `discordAction` (deployed triggers never run their node body). Interaction tokens never leave the server — the trigger emits an opaque ref. The interactions endpoint is a plugin router, not a `WebhookSource`: Discord's validation probe requires **401** (the generic intake raises 400) and the 3-second ACK deadline precludes awaiting `emit`. Rate limiting is reactive; the invalid-request guard is **process-global** because Cloudflare bans the source IP, not the token. |
| **[Sarvam AI Service](./docs-internal/sarvam_service.md)** | Sarvam AI (Indic-first). **`nodes/sarvam/` no longer exists** — every capability it served is now a *provider* inside an abstracted node: speech in `nodes/speech/`, and translate / transliterate / detect-language in `nodes/translate/`. What remains Sarvam-specific is `sarvamChatModel` (an **OpenAI-compat provider**: one `_COMPAT_PROVIDERS` entry + a JSON block, no provider subclass) under `nodes/model/`. **One stored key spans two auth styles** — the chat route takes `Authorization: Bearer` (what the openai SDK sends) while every other endpoint takes only `api-subscription-key`, so `SarvamCredential` declares the native header and every surface shares it. Introduces the generic **`supports_model_listing: false`** flag: Sarvam ships no `/v1/models` route, and unhandled that 404 becomes a `NodeUserError` that `AIService.fetch_models` re-raises *before* its curated fallback, breaking key validation and the model dropdown — so `OpenAIProvider.fetch_models` serves the curated list and probes with a one-token completion instead (defaults `true`; every other provider is untouched, locked by test). Also added generic **`Connection.request(files=)`** multipart support. |
| **[Speech Provider RFC](./docs-internal/speech_provider_rfc.md)** | Provider-abstracted speech — two nodes (`textToSpeech`, `speechToText`) with a `provider` dropdown, backed by **two registries, one per direction** (registry membership IS the capability: a synthesis-only vendor simply never appears in the transcription enum). Everything vendor-specific lives in the plugin folder [`server/nodes/speech/`](./server/nodes/speech/) — protocol, registries, dispatch, per-vendor modules; only the generic `services/provider_registry.py` and the vendor-neutral `services/media/` are shared. v1 providers: OpenAI (both directions), ElevenLabs (TTS), Deepgram + Groq (STT), Sarvam (both). Capabilities are JSON (`server/config/speech_defaults.json`) with per-model overrides, so no shared code branches on a provider name. **Audio never travels as bytes** — nodes return an `AudioRef` (a ~400 B reference) because a 12 MB base64 result blows Temporal's 2 MiB blob limit and retries 3× re-billing the provider each time. Backing routes: `GET/POST /api/workspace/{workflow_id}/…` in [`routers/workspace.py`](./server/routers/workspace.py). |
| **[Media Transport](./docs-internal/media_transport.md)** | How files move through the engine, and why they move as **references rather than bytes**. `services/media/` — `FileRef` (the base; `FileKind = file\|audio\|image\|video\|document`) with `AudioRef(FileRef)` as the probed-container narrowing (~400 B, `extra="forbid"` so adding a bytes field is a `ValidationError` rather than a silent regression), `write_audio` / `resolve_media` / `coerce_file_param` (the one nodes should call — accepts a `FileRef`/`AudioRef`, a bare path, or the legacy base64 envelope), `inspect_audio` (tinytag → stdlib `wave` → PCM arithmetic, **never raises**), `limits.py`, and **`preview.py` — the single owner of the inline-vs-attachment rule**, with two consumers: `routers/workspace.py` picks its `Content-Disposition` from `serves_inline`, and the gallery listing sets each row's `preview` from `preview_kind`, so the panel can never offer a preview the route refuses to serve. Containment runs through `resolve_within`, which closed a live vulnerability: a node joined a user path onto the workspace root unchecked, so `audio_file="../../credentials.db"` read the encrypted credential store and uploaded it. **The id/slug asymmetry is the trap** — workspace dirs are named by the mutable `Workflow.slug` while a ref stores the immutable `workflow_id` so refs survive rename; the lookup lives in [`services/workspace_locator.py`](./server/services/workspace_locator.py) (`resolve_workspace_root(..., allow_default=)`), and **every mutating caller must pass `allow_default=False`** or an unresolvable id silently lands the write in the shared anonymous workspace. HTTP surface in [`routers/workspace.py`](./server/routers/workspace.py): Range support comes free from Starlette's `FileResponse`; `NEVER_INLINE` is `{image/svg+xml, text/html, text/xml, application/xhtml+xml}` because `shell` / `fileDownloader` / `fileModify` can write arbitrary files into a workspace. |
| **[Data Node & Vision](./docs-internal/data_node.md)** | The `dataSource` agent tool (raw local data over two namespaces: workspace paths + operator-approved `mnt/<name>/...` external mounts with per-mount writable flags; typed bounded read tiers text/csv/json/pdf/html/xlsx/image-metadata/binary; write/append but **no delete**; `copy_to_workspace` imports mount files as real FileRefs), the machine-wide `data_mounts` allowlist (`services/data/mount_store.py` — refuses `DATA_DIR` overlap, drive roots, home dir), the `visionAnalyze` delegate tool (vision for every host model via openai/anthropic/gemini official SDK shapes + the `services/media/image_fit.py` visual-token budget), and the first native-image-blocks increment: `ContentBlock.source` (durable `file_ref` vs transient `bytes` — the wire codec raises on bytes), the `llm_media` tool-result opt-in, capability-gated `hydrate_image_blocks` in `run_native_llm_step`, Anthropic `tool_result` image encoding (openai/gemini encoders pending; their `vision.enabled` stays false until then). |
| **[Canvas Node](./docs-internal/canvas_node.md)** | The `canvas` node — pushed-content display board (the Claude/ChatGPT-Canvas analog) in [`server/nodes/tool/canvas/`](./server/nodes/tool/canvas/). Dual control: agents call the locked `canvas(...)` tool (paths / url / 64KB-capped markdown notes, append/replace), workflow edges auto-display upstream FileRefs via a structural scan of `connected_outputs` (`tts → canvas` is a zero-config edge; the node is the one addition to `NodeExecutor._NEEDS_CONNECTED_OUTPUTS`). Board = plugin-owned `canvas_boards`/`canvas_items` tables (mount_store mechanism, 200-item FIFO), identity-only `canvas_updated` broadcast (direct, no canary consumer), `canvas_list/remove/clear` WS handlers with the simple_memory security preamble. **Three frontend hosts share one renderer**: the `isCanvasPanel` parameter panel, and the Canvas tab of the Workspace dock on each screen, the editor's (`CanvasDock` + `canvasDockStore`; auto-open via `notifyPushed`, ephemeral click-to-preview from the gallery dialog) and Home's (`features/home/workspace/`), which are one dock (`components/workspace/WorkspaceDockShell` around `features/home/workspace/WorkspaceView`). Renders media carousel with follow-latest image poll, markdown/code/JSON via the repo's first capped client-side text fetch (`useWorkspaceText`, 512KB), external URLs + workspace HTML in strictly sandboxed iframes (never `allow-same-origin`; `NEVER_INLINE` untouched), and inline PDF via the `INLINE_EXACT` exact-match addition to `preview.py`. The current Browser node persists screenshots as workspace FileRefs through `nodes/browser/_screenshots.py`; live viewing uses the separate Browser workspace stream. Items have versions (the tool's `update_id` saves the next one; `canvas_item_versions` keeps the newest 50 per item; `canvas_version` reads one) and a note shows as a document (`NoteView`: ‹ v2/3 ›, Preview/Markdown, Copy, Download); a note the agent answering the owner writes is named on its reply (`services/chat/parts.show_artifact`) as a card that opens the board at that version (Home: `homeStore.openCanvasItem`; Dev: `canvasDockStore.focusItem`). |
| **[Translate / Transliterate / Detect](./docs-internal/speech_provider_rfc.md#8-the-pattern-generalised-nodestranslate)** | The second application of the multi-vendor pattern, and the one that retired `nodes/sarvam/`. Three nodes (`translateText`, `transliterateText`, `detectLanguage`) in [`server/nodes/translate/`](./server/nodes/translate/), backed by **three registries — one per capability**, because the asymmetry is sharper than speech's: DeepL translates and does nothing else, while Sarvam and LLM-backed providers do all three. Membership makes an unsupported selection unrepresentable rather than a runtime failure inside a paid call. Providers: **deepl** (`Authorization: DeepL-Auth-Key`, `text` is an array, free keys ending `:fx` route to a different host, and it returns `billed_characters` so cost is the provider's own figure), **sarvam** (all three over dedicated REST), **openai** (all three via `ChatUnifier` — proves the Protocol does not care what a provider does internally; billing deliberately NOT recorded because that path bills tokens the LLM layer already costs). Capabilities in `server/config/translate_defaults.json`. |
| **[Desktop App](./docs-internal/desktop_app.md)** | Electron shell in [`desktop/`](./desktop/) — a standalone bun package (NOT a root workspace member; `cli/tests/test_release_pipeline_config.py` locks `workspaces`). Bundles pinned `uv`, python-build-standalone CPython 3.12 and bun 1.4.0 — the only JavaScript runtime shipped; no Node, no npm (`desktop/runtimes.json`, verified against upstream checksum manifests by `desktop/scripts/fetch-runtimes.ts`, which also fetches bun's `LICENSE.md` from the bun repo — the release zip carries none — and prunes stale runtime dirs such as a leftover `node/`), stages the backend sibling layout from `bun pm pack --dry-run` (`desktop/scripts/stage.ts`, which runs the sidecar package's own `bun run build` — `bun build --target=bun`, express inlined — and copies `dist/index.js`), sets `OPENCOMPANY_BUN_BIN` / `BUN_INSTALL=<userData>/bun` / `BUN_INSTALL_CACHE_DIR` and prepends the bundled bun dir (then uv) to PATH for the backend (`env.ts`; `OPENCOMPANY_NODE_BIN` no longer exists), provisions the venv into Electron `userData` on first launch with `uv sync --frozen --python <bundled>` (stamp = sha256 of uv.lock + pyproject + Python + uv versions; falls back to `uv python install 3.12`), pre-warms `PYTHONPYCACHEPREFIX` (with a prefix set Python ignores in-tree .pyc — 32 s vs 3 s first boot), spawns uvicorn on `127.0.0.1:5678` (persisted port; attaches to a running `company serve` instead of double-spawning), waits on `/health/ready`, loads the backend-served SPA (zero frontend changes), routes `window.open` / `_blank` to the OS browser. Quit = `POST /api/desktop/shutdown` + stdin EOF, with a 75 s shell grace period before tree-kill. electron-builder: NSIS x64, DMG+zip arm64 only (no Intel Mac build: cryptography 49+ and dependency-injector publish no Intel macOS wheels, so the first-launch `uv sync` would need Rust and a C compiler), AppImage+deb x64, macOS signed ad-hoc with no certificate (`mac.identity: "-"`; `signIgnore` leaves the bundled runtimes under `Resources/` as their makers signed them; up to v0.3.1 `identity: null` shipped no sealed signature, so macOS called the downloaded app damaged, #154), no notarization or Windows certificate yet (macOS auto-update notify-only until Developer ID signed — Squirrel.Mac only applies an update that satisfies the installed app's designated requirement). CI: `desktop-release.yml` (tag-triggered: `prepare` drafts the GitHub Release from the annotated tag's message, three legs build and upload version-less OS-named installers such as `OpenCompany-windows-x64.exe` / `OpenCompany-macos-arm64.dmg` / `OpenCompany-linux-x86_64.AppImage` so the README can link `releases/latest/download/<file>`, each leg refuses a desktop version that differs from the tag, the macOS leg runs `codesign --verify --deep --strict` on the app inside the DMG and the zip, `finalize` undrafts once the legs and the predeploy checks pass) and `desktop-ci.yml` (typecheck/unit/invariants/Playwright smoke). **Gotcha: editor-hosted terminals export `ELECTRON_RUN_AS_NODE=1`, which makes Electron boot as plain Node (`electron.app` undefined) — strip it before launching; the e2e test does.** |
| **[Desktop Host Contract](./docs-internal/desktop_host_contract.md)** | The backend's side of being owned by a GUI shell. `core/approot.py` is the ONE module that locates the app tree (`OPENCOMPANY_APP_ROOT` / `OPENCOMPANY_CLIENT_DIST` / `OPENCOMPANY_ENV_FILE` / `OPENCOMPANY_ENV_TEMPLATE`); `tests/test_no_root_climb.py` forbids any other `Path(__file__).parents[N]` climb above `server/` and any `"client"/"dist"` / `.env.template` / `package.json` path join. `core/env_defaults.apply_file_defaults_to_environ()` does the CLI's `.env.template < .env < process env` layering at the top of `main.py`, and `Settings.env_file` is the absolute `(template, .env)` tuple — so a shell spawns `python -m uvicorn` from any cwd without the CLI package. Desktop mode (`OPENCOMPANY_DESKTOP=1`, `core/desktop.py`): parent-PID watchdog with create-time PID-reuse guard, stdin-EOF watchdog (polling via `PeekNamedPipe`/`select`, never a blocking read — a daemon thread parked in `ReadFile` at finalisation crashed exit with 0xC0000005), ctypes Job Object so children die with the backend (no pywin32 in the server venv), token-gated `POST /api/desktop/shutdown` (`routers/desktop.py`, public path; raises the signal uvicorn handles so the lifespan reaps Temporal/the bun sidecar/edgymeow), 65 s hard deadline. `GET /health/ready` (public) = DB up AND (Temporal disabled OR worker manager started), with `phase` written by `services/temporal/lifecycle.py` (`installing_temporal` / `starting_temporal` / `connecting` / `starting_workers` / `ready`). Plugin shutdown hooks now run under a 10 s per-hook timeout (`HOOK_TIMEOUT_SECONDS`), and the current browser hook uses `peek_browser_runtime()` so shutdown never constructs or installs the runtime. Historical incident: the retired `get_browser_service()` lazily installed agent-browser at shutdown and wedged graceful exit (masked under the CLI by its 5 s grace + tree-kill). `server/uv.lock` is committed; `predeploy.yml` runs `uv lock --check`. |
| **[LLM Gateway](./docs-internal/llm_gateway.md)** | Hosted LiteLLM Proxy in `gateway/` (official AWS module, mock-model local stack, contract and pin tests, `gateway-ci.yml`): pinned LiteLLM 1.103.4 and price list, served providers (Gemini on Vertex AI express mode with a Google Cloud Agent Platform key, `GOOGLE_AGENT_API_KEY`), and the contract OpenCompany relies on (wire model names, `budget_exceeded` refusals, `/user/info` allowance with Default keys, attribution headers, cache replay). Operator runbook: `gateway/README.md`. OpenCompany's side, `server/services/gateway/`: the Connectors card (`OPENCOMPANY_GATEWAY_ENABLED`), own-keys-first routing in ChatUnifier from `"gateway"` blocks in `llm_defaults.json`; the gateway's default model where the owner has set no global model (`gateway_default`, Gemini 3.8 Flash: chat Auto, employee setup, the editor's global model); and for embeddings, OpenAI speech (`"gateway": true` in `speech_defaults.json`) and vision through `key_or_route`; the RLM agent on OpenAI-format routes (`openai_prefix`); Claude Code after its login and the install's Anthropic key (`api_access`, no warm reuse); attribution from the log context, gateway error wording. |
| **[CI/CD Pipeline](./docs-internal/ci_cd.md)** | GitHub Actions workflows, predeploy validation, release publishing, and the composite setup action (bun via SHA-pinned `oven-sh/setup-bun` reading the root `packageManager` pin + Node 22 as a CI-only convenience so bun runs vite / vitest / eslint on it via their node shebangs + Python 3.12 + uv; `bun install --frozen-lockfile` everywhere; publish is `bun publish --access public` after a `bun pm whoami` credential preflight with `~/.npmrc` written from `NPM_TOKEN` — no npm CLI, no `setup-node` step of their own in the publish jobs (they get Node through the composite action), and npm provenance attestation went away with npm). Also **Cutting a release**: the annotated tag is the version and its message is the release notes; `company version sync vX.Y.Z` bumps every version file for the `chore(release)` commit the tag sits on, and `desktop-release.yml` drafts the GitHub Release from the tag message and refuses a version that differs from the tag |
| **[Performance](./docs-internal/performance.md)** | Cold-start measurements (2026-11-11, `company start` at `ac88bd98`: Application startup complete 4.5 s warm, 3.9 to 7.2 over ten launches, and 9.8 s on the first launch after a fresh build; first WS connect 0.9 s after the port answers; Temporal worker pool up at 9.6 s; `company dev` reaches the first socket in 10 to 12 s because Vite transforms modules on request. Earlier baselines kept as history: 2.90 s warm in May, ~3.6 s in September, 21.5 s cold in July, which was 71 s before the 2026-07-14 boot-delay fixes) + warm and first-launch per-phase timelines + optimisation history (deferred model-wrapper imports, lazy LLM-SDK exception refs, pre-bundled sidecar (esbuild at the time, `bun build --target=bun` today), bytecode-at-build, PartySocket reconnect, TanStack Query auth bootstrap, `manualChunks`, Vite dep-cache preservation, off-loop Temporal build-id hash) + bottleneck inventory + reproduction commands + anti-patterns to never reintroduce (incl. eager SDK import at LLM provider registration, unconditional `.vite` wipe, synchronous `Worker()` on the event loop). Pairs with the build-time pipeline doc below. |
| **[Release Build Pipeline](./docs-internal/release_build_pipeline.md)** | Build pipeline for the registry tarball (installed with `bun add -g`; bun is the only JavaScript runtime and package manager anything shipped needs): `typescript@7` (native Go compiler, exact-pinned in **root** devDependencies — client keeps `typescript@^5.x` because typescript-eslint's peer range excludes 6/7, and one manifest cannot declare `typescript` twice) for type-check, measured 5.9× faster than tsc 5.9.3 (820 ms vs 4830 ms); `bun run --filter react-flow-client typecheck` delegates up to the root gate, Vite `manualChunks` + `target: 'es2022'`, `bun build --target=bun` sidecar bundle (self-contained `dist/index.js` with express inlined, run as `bun dist/index.js`; drops the `tsx` interpreter cost and needs no `node_modules` at runtime), bytecode pre-compile in two halves — `[tool.uv] compile-bytecode = true` (uv sync compiles `.venv/` site-packages) + `python -m compileall` for project source (plain `.pyc`, **no `-O`** — per PEP 488 the non-`-O` runtimes never load `.opt-1.pyc`; excludes `.venv/`, `tests/`). Single source of truth for the bytecode-compile path list: `cli.commands.build.COMPILEALL_SOURCE_DIRS`. Dev PM is **bun@1.4** (tracked text `bun.lock`, `bunfig.toml` pins the isolated linker, ranged security pins in top-level `overrides`); `company build` step [1/6] is `bun install`. |
| **[Workflow Schema](./docs-internal/workflow-schema.md)** | JSON schema for workflows, edge handle conventions, config node architecture |
| **[Execution Engine Design](./docs-internal/DESIGN.md)** | Architecture patterns, design standards, and implementation details for the workflow execution engine |
| **[Execution Roadmap](./docs-internal/ARCHIVE/ROADMAP.md)** | *Archived* status snapshot; current state is in [Execution Engine Design](./docs-internal/DESIGN.md) and the Temporal docs |
| **[Setup Guide](./docs-internal/SETUP.md)** | Development environment setup and installation instructions |
| **[Scripts Reference](./docs-internal/SCRIPTS.md)** | `bun run` scripts, `company` CLI verbs, and their usage |
| **[Server Documentation](./docs-internal/ARCHIVE/server-readme.md)** | *Archived* at [ARCHIVE/server-readme.md](./docs-internal/ARCHIVE/server-readme.md); see [Setup Guide](./docs-internal/SETUP.md), [Authentication](./docs-internal/authentication.md), [Plugin System](./docs-internal/plugin_system.md) |
| **[Skill Creation Guide](./server/skills/GUIDE.md)** | How to create new skills (folder structure, SKILL.md format, metadata, supporting files) |
| **[Known Errors & Troubleshooting](./docs-internal/errors.md)** | Documented root causes and fixes for common errors (SQLAlchemy Windows hang, Temporal issues, WhatsApp timeouts) |
| **[New Service Integration](./docs-internal/ARCHIVE/new_service_integration.md)** | *Archived* pre-Wave-11 guide; integrate a service as one plugin folder per [Node Creation Guide](./docs-internal/node_creation.md), with [server/nodes/google/](./server/nodes/google/) as the OAuth reference |
| **[Onboarding Service](./docs-internal/onboarding.md)** | The Welcome guide on Home (`features/home/onboarding/`): three steps (Welcome, Connect an AI model, Your first hire), opened on first launch and again from the header's Guide button or Settings → Help; progress saved on `UserSettings.onboarding_completed` / `onboarding_step`; the open state lives in `homeStore.guide`, so a remount never reopens it. The same folder holds the Get started checklist (a card on the hire view only, at the Workspace dock's left edge while the dock is open; on an employee's page a header pill, `GetStartedPill`; one visibility rule in `useGetStarted`) and Home's AI models dialog (`AiModelsDialog`, opened by `homeStore.openConnectAI` instead of the shell's credentials dialog: the guide's AI model list, a plain page for one-field models in `AiModelPage`, Disconnect confirmed by `ConfirmDisconnect`) |
| **[CLI Services Integration](./docs-internal/cli_services_integration.md)** | Guide for integrating CLI-based services (Temporal, etc.) with proper lifecycle management |
| **[Temporal Architecture](./docs-internal/TEMPORAL_ARCHITECTURE.md)** | Distributed workflow execution: activities, FIRST_COMPLETED scheduling, horizontal scaling |
| **[Native LLM SDK](./docs-internal/native_llm_sdk.md)** | Native SDK layer in `services/llm/`: protocol-based providers, config-driven base URLs, and 14 registered providers (11 cloud, Ollama / LM Studio, and `openai_compatible`, which serves any number of user-named endpoints — llama.cpp, vLLM, a LiteLLM proxy — added in the Credentials Modal with no code; xAI is agent-selectable without a standalone chat-model node). A JSON-driven `supports_model_listing: false` flag covers endpoints that speak the OpenAI chat wire format but ship no `/v1/models` route (Sarvam) — `OpenAIProvider.fetch_models` then serves the curated list and probes the key with a one-token completion. `chat(on_event=...)` streams `StreamEvent`s only for providers whose block declares `"streaming": true` (Anthropic, OpenAI); the unifier replays the finished response into the sink for the rest, and the returned response never changes (`tests/llm/test_streaming.py`). Every provider is native; the dependency-boundary tests guard production imports and container startup. |
| **[RFC-0003 — OpenAI-Compatible Provider Contract](./RFC-0003-OPENAI-COMPATIBLE-PROVIDER-CONTRACT.md)** | Accepted, implemented. "OpenAI-compatible" is a family of divergent shapes with no spec behind it, so per-provider facts are **resolved at save time or declared**, never guessed. A configured `base_url` is copied **verbatim** from vendor docs and never rewritten (`/v1` is neither universal nor positionally predictable — DeepSeek is root-mounted, Groq is `/openai/v1`); a **user-supplied** URL is rooted by `services/llm/endpoints.py::resolve_base_url`, which sends the SDK's own `models.list()` to the URL then to it plus `/v1` and adopts only a body that **is an OpenAI list** — a status alone proves nothing, because LM Studio answers HTTP 200 to routes it does not serve. A failed save writes and broadcasts nothing. One `resolve_credential` sends the stored key or the vendor-documented placeholder (`auth.placeholder_key`) and never `None` (which would make the SDK read `OPENAI_API_KEY` and ship it to a third-party URL); no provider rewrites a key. A 2xx with no `choices` and an error body raises `PROTOCOL` naming the redacted URL. **Named endpoints (D13–D17)**: any server is a provider reference `openai_compatible:<slug>` stored exactly like Ollama (`{ref}` + `{ref}_proxy`), selectable from every agent (the `provider` field is a loader-driven `ProviderRef`, not a `Literal`), from the `openaiCompatibleChatModel` node (its field is `endpoint`, not `provider`, to dodge the panel's stored-key effect) and the global default picker; kind detected once from native routes by body shape; context and price from the server first (Ollama/LM Studio SDK probes, llama.cpp `/props`, vLLM `max_model_len`), then LiteLLM's table for generic servers only, then conservative defaults; local-model entries and the LiteLLM cache live under DATA_DIR, never in the tracked `config/model_registry.json`. RLM cannot route an endpoint and refuses one loudly. Gates AG1–AG20 (AG5 retired), `ProviderSpec` gains no field. |
| **[Event Waiter System](./docs-internal/event_waiter_system.md)** | In-memory asyncio.Future waiter for push-based trigger nodes on the canvas-Run path (WhatsApp, Telegram, Webhook, Chat, Task completion). Deployed triggers ride the Temporal canary path; the Redis-Streams backend was retired in Wave 15.3 |
| **[Credentials Encryption](./docs-internal/credentials_encryption.md)** | Fernet + PBKDF2 encryption pipeline, separate credentials.db, two credential systems (OAuth vs API keys), multi-backend abstraction |
| **[Status Broadcaster](./docs-internal/status_broadcaster.md)** | WebSocket-first communication: StatusBroadcaster singleton, live count via `len(MESSAGE_HANDLERS) + len(get_ws_handlers())`, broadcast message types, Android two-state model |
| **[RLM Service](./docs-internal/rlm_service.md)** | Recursive Language Model agent with REPL-based execution (llm_query, rlm_query, FINAL) |
| **[Claude Code Agent](./docs-internal/claude_code_agent.md)** | Hub for the Claude Code agent — routes to architecture, interactive mode, CLI framework, canonical-patterns RFC, and the 5 CLI snapshot references. |
| **[CLI Agent Framework](./docs-internal/cli_agent_framework.md)** | Multi-provider CLI runtime (Claude Code / Codex / Gemini): `AICliService.run_batch`, per-task worktree isolation, FastMCP bridge, **Claude memory bridge** (host-minted `--session-id` on cold spawn + intra-process stream-json multi-turn + `--resume <last_session_id>` for memory-bound runs and crash recovery — never `--continue`, which the CLI resolves only against interactive sessions — all in a stable per-workflow git worktree `<workspace>/<node>/wt_session` of the enclosing repo, detached so it adds no branch there, never the repo root itself; `node_parameters_updated` broadcast on every successful turn). **Plugin-folder layout** (post-cutover): all claude-specific code lives in [`server/nodes/agent/claude_code_agent/`](./server/nodes/agent/claude_code_agent/) — `_provider.py`, `_pool.py`, `_skills.py`, `_oauth.py`, `_handlers.py` — and self-registers via three `factory.py` registries (`register_provider` / `register_session_pool` / `register_skill_materialiser`) plus `register_ws_handlers`. The generic framework at `services/cli_agent/` imports nothing from `nodes/`. |
| **[Claude Code Interactive Mode](./docs-internal/claude_code_interactive_mode.md)** | The interactive-mode cutover: OpenCompany no longer uses `claude -p` headless. `ClaudeSessionPool` (keyed by the RFC-0002 conversation key `context_bridge.pool_key`, legacy fallback `simpleMemory.node_id`) spawns `claude` as a plain subprocess with stdio pipes — **no PTY** — and drives it over the VSCode-extension protocol: `--output-format stream-json --input-format stream-json --verbose --ide`. User prompts written as JSON to `proc.stdin` (`{"type":"user","message":{"role":"user","content":"..."}}\n`); `system/init` / `assistant` / `result` / `system/compact_boundary` events stream back on `proc.stdout` (parsed by a background `stdout_reader_task` — the on-disk JSONL is persistence-only, not the runtime contract, because `result` events are stdout-only in stream-json mode). Cross-platform via plain pipes (the earlier PTY pattern was broken on Windows because pywinpty/ConPTY's emulated stdin never reached claude's Ink TUI keystroke handler). Stays in interactive billing — entrypoint `claude-vscode`, NOT `sdk-cli`. Multi-turn within one warm subprocess preserves the session UUID (verified end-to-end); cross-batch continuity via a host-minted `--session-id <uuid4>` on the first cold spawn and `--resume <UUID>` afterwards (never `--continue`, which the CLI resolves only against interactive sessions). Four typed CloudEvents (`claude.session.{spawned,cleared,terminated,usage}`) fire from the pool. `/compact` events forward to `CompactionService`. Every Claude run routes through the pool (unbound runs get an ephemeral session per task, terminated after the batch); `AICliSession` (PTY + JSONL watcher) is no longer used for Claude because a PTY stdin makes the CLI reject stream-json input and that path never delivered the prompt (GitHub #133 / #134). A child exiting without a `result` wakes `send_turn` on stdout EOF with the exit code instead of burning the turn timeout. The CLI version is pinned via `package_version` in `config/ai_cli_providers.json`. |
| **[Codex CLI Agent](./docs-internal/codex_agent.md)** | Managed browser/device login, shared-tree pinned CLI, isolated `CODEX_HOME`, Auto versus explicit models, saved null-list compatibility, stdout JSONL parsing, MCP scope, readable provider failures, whole-node Temporal Activities, and backend restart/log diagnosis. Codex uses one process per task; Claude native pooling/resume does not apply to it. |
| **[CLI Agent Canonical Patterns RFC](./docs-internal/cli_agent_canonical_patterns_rfc.md)** | Audit of OpenCompany's `services/cli_agent` against the official Claude Code spec — six invariants (skills-as-files, MCP-only transport, `list_changed`, deferral, visible-tool filtering, native-session-continuity-via-stable-cwd) with current implementation status. |
| **[Claude Code CLI Reference (snapshot)](./docs-internal/claude_code_cli_reference.md)** | Verbatim snapshot of [code.claude.com/docs/en/cli-reference](https://code.claude.com/docs/en/cli-reference) — every CLI subcommand + flag + system-prompt-flag matrix + the subset we emit from `nodes/agent/claude_code_agent/_provider.py`. Fetched 2026-05-11. |
| **[Claude Code Env Vars (snapshot)](./docs-internal/claude_code_env_vars_reference.md)** | Categorised [code.claude.com/docs/en/env-vars](https://code.claude.com/docs/en/env-vars) snapshot — auth, Bedrock/Vertex, model, bash, MCP, telemetry, session/debug, paths. Documents `CLAUDE_CONFIG_DIR`, `MAX_MCP_OUTPUT_TOKENS`, `ENABLE_TOOL_SEARCH` which we touch directly. |
| **[Claude Code Permission Modes (snapshot)](./docs-internal/claude_code_permission_modes_reference.md)** | Verbatim [code.claude.com/docs/en/permission-modes](https://code.claude.com/docs/en/permission-modes) — `default` / `acceptEdits` / `plan` / `auto` / `dontAsk` / `bypassPermissions` semantics, Shift+Tab cycle, protected paths. OpenCompany's default is `dontAsk` (`default_permission_mode` in `server/config/ai_cli_providers.json`). |
| **[Claude Code Headless / Print Mode (snapshot)](./docs-internal/claude_code_headless_reference.md)** | Verbatim [code.claude.com/docs/en/headless](https://code.claude.com/docs/en/headless) — `claude -p`, `--output-format` (`text` / `json` / `stream-json`), `--input-format`, `--bare`, stream-json event schema (`system/init`, `system/api_retry`, `system/plugin_install`). OpenCompany no longer uses `-p`; the pool path emits `--output-format stream-json --input-format stream-json --verbose --ide` over stdio pipes (interactive billing). The event schema is the contract `nodes/agent/claude_code_agent/_pool.py` parses off `proc.stdout`. |
| **[Claude Code Skills (snapshot)](./docs-internal/claude_code_skills_reference.md)** | Verbatim [code.claude.com/docs/en/skills](https://code.claude.com/docs/en/skills) — `SKILL.md` frontmatter spec, discovery paths, content lifecycle, `context: fork`, dynamic context injection via `` !`<command>` ``. The spec OpenCompany materialises connected skills against in `_pre_spawn`. |
| **[Autonomous Agent Creation](./docs-internal/ARCHIVE/autonomous_agent_creation.md)** | *Archived* external research notes on Code Mode agents; the shipped plugin is [server/nodes/agent/autonomous_agent/](./server/nodes/agent/autonomous_agent/) |
| **[Polyglot Server](../polyglot-server/ARCHITECTURE.md)** | Plugin registry microservice with MCP gateway (optional integration) |
| **[Authentication](./docs-internal/authentication.md)** | JWT/cookie auth: toggle, single/multi mode, backend + frontend, middleware, startup retry. |
| **[Credentials Panel](./docs-internal/ARCHIVE/credentials_panel.md)** | *Archived* pre-refactor modal reference; its §5 invariants are still what `server/tests/credentials/` and `client/src/test/` lock. The live, config-driven modal is described in [Frontend Architecture → Credentials](./docs-internal/frontend_architecture.md). |
| **[Memory Lifecycle](./docs-internal/ARCHIVE/memory_lifecycle.md)** | *Archived — pre-RFC-0002.* Documented the retired `input-memory` markdown model. See "Context and Memory (RFC-0002)" below, [Agent Context Flow](./docs-internal/agent_context_flow.md) and [Memory Compaction](./docs-internal/memory_compaction.md). |
| **[Node Parameter Panel](./docs-internal/node_panels.md)** | Three-section node config UI (Input / Parameters / Output) logic-flow reference. |
| **[Deployment (legacy reference)](./docs-internal/deployment_legacy.md)** | `company deploy` CLI summary + the historical Docker Compose topology. |
| **[Docker (self-hosting)](./docs-internal/docker.md)** | One-container image built from source (`docker/Dockerfile`, `docker-compose.yml`). The build stage runs the canonical `bun install --frozen-lockfile` + `bun run build` (then `uv sync --frozen --no-dev --extra docs`); the runtime stage keeps only what `core/approot.py` reads. uvicorn runs directly, the way the desktop shell runs it (never `company serve`, whose supervisor would add a second tree-kill deadline, `backend_shutdown_grace_seconds` in `cli/_common.py`, independent of compose's `stop_grace_period`). All state on one `/data` volume; secrets minted on first start into `OPENCOMPANY_ENV_FILE=/data/opencompany.env`; Temporal embedded (CLI pre-seeded through named-volume copy-up); no Redis; no Node (`/usr/local/bin/node` links to bun). Optional system tools come only through the `EXTRAS` build arg (`browser`: Chromium + Xvfb + uv; `git`). Compose publishes the port on `127.0.0.1` only, because login is off until the operator turns it on. `/ws/internal` (where workers call back and `execute_node` runs any node) requires the `SECRET_KEY`-derived `X-OpenCompany-Internal-Token`, and every WebSocket handshake refuses a foreign `Origin` (`services/authz/ws_session.py`; see [Authentication](./docs-internal/authentication.md)). |
| **[GCP VM Deploy Runbook](./docs-internal/gcp_vm_deploy_runbook.md)** | Manual gcloud + Cloudflare runbook for deploying the released `@zeenie-ai/opencompany` tarball with `bun add -g` (non-Terraform path). |

## Design Principles & Standards

**CRITICAL: Always follow these principles when modifying backend execution code:**

### 0. Adding a new node — the canonical recipe (Wave 11.H)

Every plugin is a self-contained folder under `server/nodes/<group>/<plugin>/` rooted at `__init__.py`. `BaseNode.__init_subclass__` auto-registers metadata, schemas, handlers, and Temporal activity on import — zero edits anywhere else.

**Where to look:**
- [server/nodes/README.md](./server/nodes/README.md) — 5-minute walkthrough with the canonical folder template
- [docs-internal/plugin_system.md → Self-contained plugin folders](./docs-internal/plugin_system.md#self-contained-plugin-folders) — full reference, plus the **generic registries** plugins self-wire into (`grep -rn '^def register_' server/services server/core` for the live list)
- [docs-internal/node_creation.md](./docs-internal/node_creation.md) — decision tree for action / trigger / tool / dual-purpose / specialized-agent nodes
- [server/nodes/telegram/](./server/nodes/telegram/) — reference implementation of the multi-file split (`_service.py` / `_handlers.py` / `_filters.py` / `_refresh.py` / `_credentials.py` / `_events.py` / two node files)

**Wire format is the contract — not module paths.** The frontend identifies plugin commands by WebSocket message-type strings (`telegram_connect`, `telegram_status`, …). Moving handler bodies between Python files is invisible to the frontend so long as the registered keys stay the same.

**Don't** import the plugin folder from `routers/` / `services/` / another `nodes/` subfolder. **Don't** edit `event_waiter.py` / `status_broadcaster.py` / `routers/websocket.py` to add a plugin's handler / filter / refresh — register from the plugin's `__init__.py` instead.

### 1. Use Existing Patterns - No Tribal Code
- **Never add ad-hoc workarounds** - Use the established patterns documented in DESIGN.md
- **Conductor Decide Pattern** - All orchestration goes through `_workflow_decide()` loop
- **Continuous Scheduling** - Dependent nodes start immediately via `asyncio.wait(FIRST_COMPLETED)`; the layer-barrier Fork/Join helper (`_execute_parallel_nodes`) was removed
- **Prefect Task Caching** - Cache results via `hash_inputs()` and `generate_cache_key()`
- **Distributed Locking** - Redis SETNX, used only on the default-off `REDIS_ENABLED=true` parallel branch (`services/workflow.py::_execute_parallel`); Temporal owns concurrency on the shipped path

### 2. State Management
- **Isolated Execution Contexts** - Each workflow run has its own `ExecutionContext`
- **No Global State** - Never use module-level variables for execution state
- **Cache Persistence** - Execution state persists to SQLite by default; Redis only when `REDIS_ENABLED=true` (`core/config.py`, default `False`; no shipped configuration enables it)
- **Explicit State Machines** - Tasks follow `TaskStatus` enum, workflows follow `WorkflowStatus`

### 3. Separation of Concerns
- **Models** (`models.py`) - Pure data structures, JSON-serializable, no business logic
- **Cache** (`cache.py`) - Redis persistence abstraction only
- **Executor** (`executor.py`) - Orchestration logic, decide pattern implementation
- **Recovery** (`recovery.py`) - Heartbeat and crash recovery only
- **Conditions** (`conditions.py`) - Edge condition evaluation for runtime branching

### Backend Service Architecture (n8n-inspired)
The workflow backend follows modular architecture patterns from n8n, Temporal, and Conductor:

(abbreviated: only the modules discussed in this file; see the directory for the full set)

```
server/services/
├── workflow.py              # Facade (~840 lines) - thin coordinator
├── node_executor.py         # Single node execution with registry pattern
├── parameter_resolver.py    # Template variable resolution
├── agent_team.py            # AgentTeamService for multi-agent coordination
├── model_registry.py        # ModelRegistryService - model constraints from OpenRouter + llm_defaults
├── pricing.py               # LLM and API cost calculation (loads config/pricing.json)
├── markdown_formatter.py    # GFM markdown to platform-specific formatting (Telegram HTML, WhatsApp, plain)
├── ws_handler_registry.py   # Plugin-owned WS commands self-register here (Wave 11.H)
├── todo_service.py          # TodoService singleton for writeTodos tool (JSON per-session state)
├── media/                   # Media transport — vendor-neutral, kind-agnostic (see media_transport.md)
│   ├── refs.py              # FileRef (base; FileKind = file|audio|image|video|document)
│   │                        # + AudioRef(FileRef), the probed-container narrowing.
│   │                        # A reference, never bytes; extra="forbid" makes that structural.
│   │                        # kind="audio" ASSERTS inspect_audio ran — never guess it.
│   ├── preview.py           # Single owner of the inline-vs-attachment rule (serves_inline /
│   │                        # preview_kind). TWO consumers: routers/workspace.py picks the
│   │                        # Content-Disposition, the gallery listing sets each row's
│   │                        # `preview`. One function, so the panel can never offer a preview
│   │                        # the route refuses to serve inline.
│   ├── workspace.py         # write_audio / resolve_media / read_media_bytes / coerce_file_param
│   ├── inspect.py           # tinytag -> wave -> PCM arithmetic; NEVER raises
│   └── limits.py            # every size constant, each annotated with what it defends against
├── provider_registry.py     # Generic ProviderSpec + ProviderRegistry + lazy exception refs.
│                            # Shared by services/llm and nodes/speech (which owns two, one per
│                            # direction). Registration must never import a provider SDK.
├── memory/                  # Native Markdown/JSONL/runtime/vector-store helpers
├── memory_store.py          # In-memory conversation sessions using native Message values
├── skill_prompt.py          # Skill system prompt builder (injects SKILL.md for personality skills)
├── text.py                  # TextService (text generation nodes)
├── chat_client.py           # JSON-RPC 2.0 WebSocket client for chat backend
│                            # (Claude Code CLI wrapper + isolated OAuth moved to
│                            #  nodes/agent/claude_code_agent/_oauth.py — CLAUDE_CONFIG_DIR=<DATA_DIR>/claude/)
├── tracked_http.py          # HTTPX event hooks for automatic API cost tracking
├── handlers/                # Cross-cutting orchestration only (Wave 11: 16 → 4 files, 12.8K → 1.1K LOC)
│   ├── tools.py             # AI-tool dispatch + agent delegation (~1,020 LOC)
│   ├── triggers.py          # Generic event-trigger handler
│   ├── todo.py              # writeTodos execution shim (used by every agent)
│   └── __init__.py          # Docstring only (google_auth.py retired into nodes/google/_auth_helper.py)
├── llm/                     # Native LLM provider SDKs for chat and every new agent execution
│   ├── __init__.py          # Public API exports
│   ├── protocol.py          # ThinkingConfig, Message, LLMResponse, LLMProvider Protocol
│   ├── config.py            # ProviderConfig, resolve_max_tokens, resolve_temperature, split_provider_ref, resolve_credential
│   ├── endpoints.py         # RFC-0003 save-time half: resolve_base_url roots a user-supplied URL, redact_url, endpoint listing, unconfigured_endpoint_message
│   ├── media.py             # Tool-result images: refs in durable state, bytes only at the provider boundary (hydrate_image_blocks)
│   ├── schema.py            # JSON-schema preparation for native provider tool APIs (compile_tool_schema)
│   ├── registry.py          # ProviderSpec + register_provider — sdk_exception_refs are LAZY "module:Class" strings resolved via pkgutil.resolve_name at except/read time; NEVER import an SDK at registration (locked by tests/llm/test_lazy_sdk_imports.py)
│   ├── unifier.py           # ChatUnifier facade — routes chat/fetch_models, translates typed SDK errors → NodeUserError, applies incompatible_models filter
│   ├── vertex.py            # Vertex / Agent-Platform key handling
│   ├── messages.py          # filter_empty_messages, is_valid_message_content
│   └── providers/           # Per-provider implementations (+ _compat.py: every name in _COMPAT_PROVIDERS, plus the base-URL-less openai_compatible registration that serves named endpoints)
│       ├── anthropic.py     # AnthropicProvider (anthropic SDK)
│       ├── openai.py        # OpenAIProvider (openai SDK)
│       ├── gemini.py        # GeminiProvider (google-genai SDK)
│       └── openrouter.py    # OpenRouterProvider (extends OpenAIProvider)
├── proxy/                   # Residential proxy provider management
│   ├── __init__.py          # Exports get_proxy_service, ProxyService
│   ├── service.py           # ProxyService singleton - provider selection, URL generation
│   ├── providers.py         # TemplateProxyProvider - JSON url_template formatting
│   └── models.py            # ProxyProvider, RoutingRule, SessionType enums
├── deployment/              # Event-driven deployment lifecycle
│   ├── __init__.py
│   ├── state.py             # DeploymentState, TriggerInfo dataclasses
│   ├── triggers.py          # TriggerManager (cron, event triggers)
│   └── manager.py           # DeploymentManager (deploy, cancel, status)
├── execution/               # Parallel workflow orchestration
│   ├── models.py            # ExecutionContext, TaskStatus
│   ├── executor.py          # WorkflowExecutor with decide pattern
│   ├── cache.py             # Cache persistence (Redis/SQLite)
│   └── recovery.py          # Crash recovery
└── temporal/                # Distributed workflow execution (optional)
    ├── __init__.py          # Exports TemporalExecutor, TemporalClientWrapper
    ├── workflow.py          # MachinaWorkflow orchestrator
    ├── activities.py        # Class-based activities with connection pooling
    ├── worker.py            # TemporalWorkerManager + run_standalone_worker()
    ├── executor.py          # TemporalExecutor interface
    ├── client.py            # Temporal client wrapper
    └── ws_client.py         # WebSocket connection pool

server/core/
├── container.py             # Dependency injection container
├── database.py              # SQLite database with cache CRUD methods
├── cache.py                 # CacheService with Redis/SQLite/Memory fallback
├── config.py                # Application configuration
├── logging.py               # Logging configuration
├── paths.py                 # SSOT for on-disk locations (generic helpers only): opencompany_root()/data_path()/workspaces_dir()/workspace_dir()/daemons_dir() + packages_dir()/package_dir(name) all under ~/.opencompany/ (= DATA_DIR); packages/ holds the single shared bun-managed packages tree (core/js_runtime.py) + stripe/ + temporal/ binary subdirs; example_workflows_dir() = <repo>/.opencompany/workflows/ (shipped seeds, NOT under DATA_DIR). Plugin-specific subpaths composed inline at the call site, never added here.
├── env_defaults.py          # Env accessor backed by .env.template/.env (the SSOT for port numbers; used by entry points that bypass the CLI env push)
├── encryption.py            # Fernet encryption with PBKDF2 key derivation
├── credentials_database.py  # Async SQLite for encrypted API keys and OAuth tokens
└── credential_backends.py   # Multi-backend abstraction (Fernet, Keyring, AWS)

server/models/
├── cache.py                 # CacheEntry SQLModel for SQLite cache
├── auth.py                  # User model with bcrypt
└── database.py              # ConversationMessage, NodeParameter, ToolSchema, ChatMessage, TokenUsageMetric, APIUsageMetric, CompactionEvent, SessionTokenState, UserSettings, ProviderDefaults, AgentTeam, TeamMember, TeamTask, AgentMessage tables

server/config/
├── llm_defaults.json        # Per-provider defaults (model, base_url, max_output_tokens, context_length, temperature_range, reasoning_models, thinking_type, ...) AND a top-level `agent` block (recursion_limit, default_temperature, compaction.ratio) that drives the agent loop and CompactionService. recursion_limit and compaction.ratio are last-resort fallbacks: AGENT_RECURSION_LIMIT / COMPACTION_RATIO via core.config.Settings win (ModelRegistryService.get_agent_defaults); default_temperature is JSON-only.
├── model_registry.json      # Cached model data from OpenRouter (auto-refreshed). Models from the user's own servers and LiteLLM's table are cached under DATA_DIR instead (local_models.json, litellm_models.json), never here.
├── pricing.json             # LLM and API pricing config
├── google_apis.json         # Google Workspace API endpoints, scopes, OAuth callback paths
├── email_providers.json     # IMAP/SMTP provider presets (Gmail, Outlook, Yahoo, iCloud, ProtonMail, Fastmail, custom)
├── speech_defaults.json     # Per-provider TTS/STT capabilities, per-direction, with per-model overrides
                             # ({"whisper-1": [...], "_default": [...]} resolved exact -> longest-prefix ->
                             # _default). Drives the provider dropdowns, voice/model loaders and validation.
                             # Boolean flags default PERMISSIVE so a missing declaration never silently
                             # disables a working feature. Read by nodes/speech/_config.py.
└── translate_defaults.json  # Same shape, three capability blocks per provider (translate /
                             # transliterate / detect). Both files are resolved by the shared
                             # services/plugin/capabilities.CapabilityConfig.

server/nodejs/                   # JS/TS executor sidecar — Express on bun (the only JS runtime shipped)
├── package.json                 # Dependencies: express + express-rate-limit (both inlined by `bun build --target=bun`); engines.bun >= 1.4.0
├── tsconfig.json                # TypeScript config (ES2024)
└── src/
    └── index.ts                 # Express server (/execute, /health, /packages/*)
```

### Polyglot Server Integration (Optional)
OpenCompany can optionally integrate with the sibling **polyglot-server** repo (a plugin-registry microservice exposing REST + MCP + WebSocket). NOTE: the OpenCompany-side client/handler (`polyglot_client.py`, `handlers/polyglot.py`) are not currently present in the tree — this is a possible future integration, not wired. See [Polyglot Server](../polyglot-server/ARCHITECTURE.md).

### JS/TS Code Executor (bun sidecar)
Persistent Express server for JavaScript/TypeScript code execution, replacing subprocess spawning per execution. It runs on **bun**, the only JavaScript runtime OpenCompany ships — no Node, no npm: `server/nodejs/` is bundled by `bun build src/index.ts --target=bun --outfile=dist/index.js` (self-contained, express inlined; `company build` step [3/6], and the desktop stage script runs the same `bun run build`), and `nodes/code/_runtime.py` spawns `[bun, dist/index.js]`, resolving the binary through `server/core/js_runtime.py` (`OPENCOMPANY_BUN_BIN` override, else `bun` on PATH). Verified on bun 1.4.0 with Node stripped from PATH, including `node:vm` `runInContext` with the timeout. **Plugin-owned (July 2026):** the sidecar is supervised by `nodes/code/_runtime.py` (`NodeJSExecutorRuntime`, same `BaseProcessSupervisor` pattern as WhatsApp/Temporal) and spawns on demand from `acquire_client()` on the first JS/TS node execution — in every run mode, with no CLI service wiring. Client + config are plugin-owned too (`nodes/code/_client.py`, `NODEJS_EXECUTOR_*` env vars; core `Settings` carries no executor fields).

**Architecture:**
```
┌─────────────────────────────────────────────────────────────┐
│           Python Backend (PYTHON_BACKEND_PORT)               │
│  ┌────────────────┐     HTTP/JSON      ┌──────────────────┐ │
│  │ NodeJSClient   │◄──────────────────►│  bun sidecar     │ │
│  │ (aiohttp)      │ NODEJS_EXECUTOR_PORT │ (Express)      │ │
│  └────────────────┘                    └──────────────────┘ │
│         ▲                                                    │
│         │                                                    │
│  ┌──────┴─────────┐                                         │
│  │ nodes/code/    │                                         │
│  │ plugins        │                                         │
│  └────────────────┘                                         │
└─────────────────────────────────────────────────────────────┘
```

**Files:**
```
server/nodejs/
├── package.json              # Dependencies: express + express-rate-limit (both inlined into the bundle; the limiter guards the /packages routes). Scripts: build = `bun build src/index.ts --target=bun --outfile=dist/index.js`,
│                             #   start = `bun dist/index.js`, dev = `bun --watch src/index.ts`; engines = { bun >= 1.4.0 }. No esbuild, no tsx.
├── tsconfig.json             # TypeScript config (ES2024)
├── dist/index.js             # Self-contained bundle (gitignored; built by `company build` / the desktop stage script; missing from the 0.2.x registry tarball, see errors.md #26)
└── src/
    └── index.ts              # Express server with /execute, /health, /packages/*

server/core/js_runtime.py         # How the backend finds bun (OPENCOMPANY_BUN_BIN, else PATH) + the shared packages tree helpers

server/nodes/code/                # Executor plugins (javascript_executor, typescript_executor) + _runtime.py (spawns [bun, dist/index.js]) + _client.py
```

**Endpoints:**
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check: `runtime: "bun"`, `runtime_version`, and still `node_version` (the Node API level bun reports) |
| `/execute` | POST | Execute JS/TS code with input_data and timeout (`language: "typescript"` is type-stripped by `Bun.Transpiler` first, then both run in a `node:vm` context) |
| `/packages/install` | POST | Install npm-registry packages into user-packages via `bun add` (run through `process.execPath`, i.e. the sidecar's own bun). Rate-limited to 120 req/min |
| `/packages` | GET | List installed packages from the user-packages dir's `package.json`. Rate-limited to 120 req/min, sharing the window with `/packages/install` |

Every route but `/health` answers 401 unless the request carries `X-OpenCompany-Executor-Token` with the token the sidecar was started with (`NODEJS_EXECUTOR_TOKEN`), compared before the body is read. `nodes/code/_runtime.py` makes the token once per backend process (an operator-set `NODEJS_EXECUTOR_TOKEN` wins) and gives it only to the sidecar it spawns; when a process it did not start holds the port, the JS/TS nodes fail with a message saying so and send it nothing.

**Environment Variables:**
| Variable | Default | Description |
|----------|---------|-------------|
| `NODEJS_EXECUTOR_URL` | `http://localhost:${NODEJS_EXECUTOR_PORT}` | Server URL for Python client |
| `NODEJS_EXECUTOR_TIMEOUT` | `30` | Request timeout in seconds |
| `NODEJS_EXECUTOR_PORT` | see `.env.template` | Server port |
| `NODEJS_EXECUTOR_HOST` | `localhost` | Server host |
| `NODEJS_EXECUTOR_BODY_LIMIT` | `10mb` | Max request body size |
| `NODEJS_EXECUTOR_TOKEN` | made per backend process | The token the sidecar requires on every route but `/health`. Set the same value in each process's environment (never in a tracked file) for an external executor (`NODEJS_EXECUTOR_URL`) or several backend processes sharing one sidecar |

**Key Modules:**

| Module | Responsibility | Pattern |
|--------|---------------|---------|
| `workflow.py` | Facade delegating to specialized modules | Facade Pattern |
| `node_executor.py` | Execute single node via handler registry | Registry + functools.partial |
| `parameter_resolver.py` | Resolve `{{node.field}}` templates | Compiled regex |
| `deployment/manager.py` | Deploy/cancel workflows, spawn runs | n8n Deployment |
| `deployment/triggers.py` | Setup cron/event triggers | Event-driven |
| `deployment/state.py` | Immutable state dataclasses | Dataclass |
| `temporal/executor.py` | Temporal-based distributed execution | Per-node Activities |
| `temporal/workflow.py` | Pure orchestrator (no business logic) | FIRST_COMPLETED |
| `temporal/worker.py` | Worker lifecycle + horizontal scaling | Connection Pooling |

**NodeExecutor Registry Pattern:**
```python
class NodeExecutor:
    def _build_handler_registry(self) -> Dict[str, Callable]:
        return {
            'start': handle_start,
            'aiAgent': _dispatch_plugin_node,  # Wave 11: routes via BaseNode.execute()
            # ... registry-based dispatch instead of if-else chains
        }
```

### 4. Dependency Injection
```python
# Correct: Receive dependencies via constructor
class WorkflowExecutor:
    def __init__(self, cache: ExecutionCache, node_executor: Callable):
        self.cache = cache
        self.node_executor = node_executor

# Wrong: Import and use global singletons
from services.some_service import global_instance
```

### 5. Error Handling & Logging
- **Log at appropriate levels**: DEBUG for routine operations, INFO for significant events, ERROR for failures
- **Never suppress errors silently** - Always log or propagate
- **Use structured logging** - Include context (node_id, execution_id, etc.)
- **Configurable via `.env`**: Set `LOG_LEVEL=DEBUG` for verbose output, `LOG_LEVEL=INFO` for production

#### Logging Configuration
```bash
# In server/.env
LOG_LEVEL=INFO                  # Default: INFO, DEBUG for verbose
LOG_FORMAT=json                 # 'json' (default) or 'text' for console
LOG_FILE=                       # Optional rotating-file destination
LOG_FILE_MAX_BYTES=10485760     # 10 MiB ceiling per file
LOG_FILE_BACKUP_COUNT=5         # Keep 5 backups (50 MiB total cap)
```

**What logs at each level:**
- `DEBUG`: Template resolution, parameter resolution, node execution details, event waiter registration, downstream traversal
- `INFO`: Workflow completion, deployment start/stop, significant state changes
- `ERROR`: Failures, exceptions, validation errors

#### Logging Infrastructure (canonical patterns)

**Console mode is timestamp-less by design.** The supervisor
(`cli/colors.py`) prepends `[HH:MM:SS.fff]` to every aggregated
line, so `configure_logging` does NOT add an inner `TimeStamper` in
console mode. JSON mode keeps ISO timestamps for machine consumers.
Helpers that print pre-logger init (`_startup_log` in `main.py`,
`_clog` in `core/container.py`) emit raw `print()` so the CLI prefix
is the single timing source.

**Context propagation via `structlog.contextvars`.** Bind once at the
entry point; every log record inside that async context picks the
fields up automatically. Stdlib `contextvars` rides `asyncio.gather`
child tasks.

```python
from core.logging import log_context

async with log_context(workflow_id=wf_id, node_id=node_id):
    await do_work()  # all logs inside carry workflow_id + node_id
```

`BaseNode.execute()` already wraps its body in
`log_context(node_id, node_type, workflow_id?)` so plugin operation
logs are auto-tagged — don't pass these as kwargs at each call site.

**Per-plugin OpenTelemetry span.** `BaseNode.execute()` opens a
`node.<type>.execute` span with attributes `node.id` / `node.type` /
`workflow.id` around the operation body. Single edit instruments every
plugin — no per-plugin span code needed.

**Source-tag resolver for the Terminal UI panel.** `record.name`
collapses to a ≤12-char tag via `_resolve_source_tag` in
`core/logging.py`:

1. `nodes.<plugin>.*` → `<plugin>` (auto-rule; no per-plugin entry)
2. `routers.<name>.*` → `<name>` (auto-rule)
3. Explicit registry `_LOG_SOURCE_TAGS` — only for cross-cutting
   services with long module names (`workflow_validator` → `validator`,
   `status_broadcaster` → `broadcaster`, `user_auth` → `auth`, etc.)
4. Second-segment fallback (`services.ai` → `ai`)

Plugins that genuinely want a different label from their folder name
call `register_log_source_tag(prefix, tag)` from their package
`__init__.py` — same self-registration pattern as the other generic
plugin registries (see the Plugin System row at the top of this file).

**RotatingFileHandler** swaps in when `LOG_FILE` is set — no
unbounded log growth.

**NodeUserError vs Exception contract** (`services/plugin/base.py`):
- `NodeUserError` → single WARN line, no traceback, structured response
- `PermissionError` annotated with `.provider` / `.reason` / `.auth` →
  `error_type="PermissionDeniedError"` + `credential` envelope block +
  CloudEvents `credential.{auth}.runtime_failed` broadcast
- Bare `Exception` → `logger.exception` with full traceback, unless it
  carries an HTTP 408, 429 or 5xx: that is one WARN line, and on Temporal
  a call that is safe to repeat and is not a send runs again under the
  plugin's `retry_policy` (RFC-0004 Q9; see
  [Plugin System](./docs-internal/plugin_system.md))

Reach for `NodeUserError` for any user-correctable failure
(missing required field, unknown enum value, bad regex). Reserve
`RuntimeError` / `Exception` for genuinely unexpected server bugs. A
provider's 408, 429 or 5xx is neither: let it escape (a plain
`response.raise_for_status()`), since wrapping it in `NodeUserError`
turns the retry off.

**Output contract enforcement** (`BaseNode._serialize_result` in
`services/plugin/base.py`): the declared `Output` Pydantic model is
enforced at the serialization boundary, FastAPI-`response_model` style.
Dict results validate via `Output.model_validate(...).model_dump(mode="json",
exclude_unset=True)`; `BaseModel` results dump `mode="json"`; violations
produce an `error_type="OutputValidationError"` envelope at the producer.
Rules: prefer returning the `Output` instance; never put raw third-party
objects (SDK results, dataclasses) or pre-stringified JSON into result
dicts — return plain lists/dicts; Params fields that may receive
LLM-stringified JSON args coerce with `field_validator(mode="before")`
(canonical: `AndroidServiceParams._coerce_parameters`,
`WriteTodosParams._coerce_todos`). Below the plugin layer, the SQLAlchemy
engine sets `json_serializer` backed by `pydantic_core.to_jsonable_python`
(`core/database.py`) so every JSON column tolerates dataclasses /
datetimes / enums / sets. Full spec:
[docs-internal/plugin_system.md → Output contract enforcement](./docs-internal/plugin_system.md);
locked by `server/tests/test_output_contract.py`.

### 6. Cleanup & Lifecycle
- **Use existing teardown methods** - e.g., `_teardown_all_cron_triggers()` for cron cleanup
- **Cleanup in finally blocks** - Ensure resources are released even on error
- **No orphan prevention hacks** - Trust the existing lifecycle management

### 7. Frontend Design + Theme System (strict)

**Always use the existing design and theme systems.** Tribal styling reintroduced anywhere defeats the migration. The following rules are non-negotiable for any new or edited frontend file:

1. **Compose shadcn primitives** from [client/src/components/ui/](./client/src/components/ui/) — `Button`, `Badge`, `Alert`, `AlertDialog`, `Dialog`, `DropdownMenu`, `Popover`, `Command`, `Select`, `Progress`, `Tooltip`, `Tabs`, `Card`, `Input`, `Textarea`, `Switch`, `Checkbox`, `Slider`, `Label`, `Form`, `Collapsible`, `Accordion`, `Skeleton`, `Sonner`. **Do not hand-roll** modals, dropdowns, menus, toasts, dialogs, or buttons when a primitive exists. Add `bun x shadcn@latest add <name>` if the primitive is missing.
2. **Action buttons → `<ActionButton intent="...">`** ([client/src/components/ui/action-button.tsx](./client/src/components/ui/action-button.tsx)). The `intent` prop is a semantic role (`run | stop | save | config | secret | tools`), never a palette color. Never re-introduce the `actionButtonStyle()` / hand-built colored buttons. On Home and in the shared chat (`features/chat`), colour marks only state or role: no button is green or any other intent colour, Start, Start again and Resume included (they are `invert`, since 2026-11-10); a screen's or card's one primary action is `Button variant="invert"` (the Workspace's Hand back too); every other button is `variant="quiet"` plus `border-border-default` (or `border-border-strong`) and `text-fg-default`, never `variant="outline"`, whose `dark:` rules beat caller classes in dark mode; switches use `tone="ink"`. The Welcome guide's buttons follow the rule too (Next and Connect an AI model are `invert`); only its demo illustration keeps its own colours. A new employee's setup card and the Get started checklist take no role or status colour at all: neutral routine dots and labels, a generated spec's `tone` undrawn, an ink bar and ink done ticks. So does the editor's top toolbar (`TopToolbar.tsx`), with no green: Start and Resume are its `invert` primary, only the save dot carries a state colour, and every other control is neutral. See [Theme System → Colour marks state or role](./docs-internal/theme_system.md#colour-marks-state-or-role).
3. **Style with Tailwind classes**, not `style={{...}}`. Inline `style` is allowed only for genuinely dynamic values (React Flow `<Handle>` positioning, runtime-computed coordinates, dynamic per-definition `nodeColor` on canvas nodes).
4. **Use the token tier table** in [docs-internal/frontend_architecture.md](./docs-internal/frontend_architecture.md#tokens--theming):
   - Generic chrome / status → shadcn semantic tokens (`bg-card`, `text-muted-foreground`, `border-border`, `text-success`, `bg-destructive`, `text-warning`, `text-info`, `bg-accent`, etc.)
   - Node-type-themed surfaces → `--node-X` role tokens (`bg-node-agent`, `bg-node-model-soft`, `border-node-skill-border`, `text-node-trigger`, `text-node-workflow`, `bg-node-tool-soft`)
   - Panel actions in the editor → `<ActionButton intent="...">` (the "soft tinted button" pill); state marks → the `--action-X` semantic role tokens (`bg-action-run-soft`, `text-action-stop`, `border-action-config-border`, etc.). No button on Home, in the shared chat, in the editor's top toolbar or in its Settings panel takes an intent's colour (rule 2)
   - **No palette names in components.** `bg-dracula-green` etc. are forbidden in non-decorative code; always go through `--action-X` or `--node-X`
5. **No opacity arithmetic at call sites.** `bg-primary/10`, `border-node-agent/30`, `${color}25` template literals are forbidden. If a unique tint is needed, add a new variant to the theme (e.g., `--node-X-soft`, `--node-X-border`) and use it by name.
6. **No theme-locked names in non-decorative code.** Avoid `bg-dracula-purple`, `text-dracula-cyan`, etc. unless the constant accent is intentional (action-button palette). Prefer the role token (`bg-node-agent`) so future themes redefine without code edits.
7. **No `useAppTheme()` in new files.** It is grandfathered for the canvas node components, which interpolate per-definition `nodeColor`; `Dashboard.tsx` still reads layout and timing constants from it, and the two panels in `components/maps/` still style inline through it. Every other surface uses Tailwind + the tokens above.
8. **Icons → `lucide-react`.** Inline SVGs are reserved for non-iconographic graphics (charts, decorative shapes). Replace any `<svg>...</svg>` icon you encounter while editing. **Icon size is a token, never a class**: backend-declared node/provider icons render through `<NodeIcon size={...}>` with `theme.nodeSize.squareIcon` on canvas nodes (36px, the one node-icon size) or a `theme.iconSize.*` step elsewhere, and `NodeIcon` applies that token as width, height AND emoji font size so every icon kind draws at the same edge. Never pass `h-*` / `w-*` / `text-*` sizing to it — the theme type scale maps `text-3xl` to 44px, which is how emoji nodes once painted half again larger than SVG-backed nodes beside them.
9. **Any `draggable` element that performs a function must ship a pointer-operable, non-drag control reaching the same end state through the same write path.** WCAG 2.2 SC 2.5.7 (Level AA); the sufficient technique is [G219](https://www.w3.org/WAI/WCAG22/Techniques/general/G219), the failure is [F108](https://www.w3.org/WAI/WCAG22/Techniques/failures/F108). Two traps worth stating: **keyboard support does not discharge it** — the Understanding doc is explicit that an equivalent counts only "*unless that equivalent keyboard operation also provides controls that can be clicked or tapped with a pointer*", because touchscreen users may have no keyboard. And the alternative must operate *the same function*, so extract the write into a shared helper rather than reimplementing it — see [`lib/workspaceFileAssign.ts`](./client/src/lib/workspaceFileAssign.ts), which both `ParameterRenderer.handleDrop` and `WorkspaceFilePickerDialog` call, with a test asserting the two paths produce deep-equal results.

When in doubt, read [docs-internal/frontend_architecture.md](./docs-internal/frontend_architecture.md) before introducing new patterns.

### 8. Naming Conventions (strict)

| Layer | Convention | Examples |
|---|---|---|
| Python identifier (function, variable, module, file) | `snake_case` | `get_user_settings`, `auth_service`, `node_allowlist.py` |
| JSON config key (read by Python) | `snake_case` | `enabled_nodes`, `default_llm_provider`, `compaction_ratio` |
| WebSocket message type (Python ↔ TS wire) | `snake_case` | `get_node_allowlist`, `save_user_settings`, `validate_api_key` |
| Database column / SQLModel field | `snake_case` | `created_at`, `auto_save_interval`, `examples_loaded` |
| Python class | `PascalCase` | `NodeAllowlistService`, `WorkflowExecutor` |
| TypeScript identifier | `camelCase` | `useNodeAllowlist`, `enabledNodes`, `isVisible` |
| TypeScript file (React hook) | `camelCase` starting with `use` | `useNodeAllowlist.ts`, `useWebSocket.ts` |
| Node type identifier | stored verbatim — **do not transform** | `aiAgent`, `httpRequest`, `openaiChatModel` (currently camelCase in this repo) |

**Crossing the wire**: payload keys between Python and TS are always `snake_case` (Python writes the payload). The TS hook receives `snake_case` keys and binds them to local `camelCase` variables; do not auto-transform across languages with a serializer.

Do not invent kebab-case or PascalCase variants for any of the rows above. The existing codebase is internally consistent — match it.

### 9. Cache System Architecture (n8n Pattern)
The cache system follows n8n's pattern with automatic fallback:

```
Opt-in (REDIS_ENABLED=true): Redis → SQLite → Memory
Default (shipped):           SQLite → Memory
```

**Configuration** (`server/.env`):
```bash
REDIS_ENABLED=false           # Local dev: use SQLite
REDIS_URL=redis://redis:6379  # Read only when REDIS_ENABLED=true; the `redis:` host is from the retired Compose topology (see deployment_legacy.md)
```

**CacheService** (`server/core/cache.py`):
```python
class CacheService:
    def __init__(self, database: Database, settings: Settings):
        self._database = database
        self._settings = settings
        self._redis: Optional[Redis] = None
        self._memory_cache: Dict[str, Any] = {}

    async def get(self, key: str) -> Optional[str]:
        # Try Redis first (if enabled)
        if self._redis:
            value = await self._redis.get(key)
            if value: return value
        # Fall back to SQLite
        entry = await self._database.get_cache_entry(key)
        if entry: return entry.value
        # Fall back to memory
        return self._memory_cache.get(key)
```

**SQLite Cache Model** (`server/models/cache.py`):
```python
class CacheEntry(SQLModel, table=True):
    __tablename__ = "cache_entries"
    key: str = Field(primary_key=True)
    value: str
    expires_at: Optional[float] = Field(default=None, index=True)  # Unix timestamp
    created_at: float = Field(default_factory=time.time)
```

**Key Methods** (`server/core/database.py`):
- `get_cache_entry(key)` - Get cache entry by key
- `set_cache_entry(key, value, ttl)` - Set with optional TTL
- `delete_cache_entry(key)` - Delete by key
- `cleanup_expired_cache()` - Remove expired entries

## Codebase Summary
- **Hybrid architecture**: Python (FastAPI + Pydantic plugins) + React/TypeScript frontend + a bun-hosted Express sidecar for JS/TS code execution (bun is the only JavaScript runtime and package manager anything shipped needs; Node is dev/CI-only).
- **Backend NodeSpec is the single source of truth.** Plugins live in [`server/nodes/<group>/<plugin>/__init__.py`](./server/nodes/) (or a single `.py` inside a domain folder) and auto-register via `BaseNode.__init_subclass__`. Authoritative node count is `len(services.node_registry.NODE_METADATA)` after `import nodes`; never a file glob (`**/__init__.py` also matches group packages, and excluding `__init__.py` drops most plugins). Folders cover agent / model / android / google / microsoft / whatsapp / whatsapp_business / twitter / telegram / discord / social / email / search / scraper / document / code / filesystem / proxy / location / chat / text / scheduler / trigger / tool / utility / workflow / skill / context / vision / browser / speech / translate / stripe / dodo_payments / vercel / github / cloudflare / gcloud / aws.
- **WebSocket-first frontend-backend communication.** Authoritative handler count is the size of the `MESSAGE_HANDLERS` dict in [`server/routers/websocket.py`](./server/routers/websocket.py) plus plugin-registered handlers via `services.ws_handler_registry`. Don't hand-maintain the count in this doc — it drifts on every plugin add.
- **Plugin-first architecture (Wave 11).** One plugin = one authoring location (a folder rooted at `__init__.py`, or a single `.py` inside a domain folder). `services/handlers/` shrank from 12.8K → 1.1K LOC across 16 → 4 files. Live invariant total via `pytest --collect-only`.

## Frontend Performance Architecture

The frontend uses a layered cache + slice-subscription model so cold refreshes are instant and high-frequency status broadcasts do not cascade through the React tree. The patterns below are canonical -- follow them when adding new server-state queries, status broadcasts, or canvas node components.

### TanStack Query persistence ([client/src/lib/queryPersist.ts](./client/src/lib/queryPersist.ts))
- App is wrapped in `<PersistQueryClientProvider>` ([main.tsx](./client/src/main.tsx)) with a localStorage persister + `__APP_VERSION__` buster + 24h SWR window (RFC 5861).
- Only queries with key prefixes `nodeSpec` / `nodeGroups` are dehydrated (see `PERSISTED_KEY_PREFIXES` / `shouldPersistQuery`). High-frequency / per-session queries stay in-memory.
- Hard refresh paints from cached specs **before** the WS connects, so canvas nodes never flash placeholder icons.

### `useNodeSpec` is a slice subscription, not a `useQuery` ([client/src/lib/nodeSpec.ts](./client/src/lib/nodeSpec.ts))
- Reads via `useSyncExternalStore` against `queryClient.getQueryCache().subscribe(...)` filtered by `hashKey(['nodeSpec', type])`. Per-spec observer count is **0**; only the matching slot triggers a re-render.
- Lazy fetch is one-shot via `useEffect`, gated on `isReady` (see below).
- **Do not re-introduce `useQuery(['nodeSpec', type])`** anywhere -- N consumers would create N observers, all woken on every cache write.
- **Critical: any cache entry consumed via `useSyncExternalStore` MUST set `gcTime: GC_TIME.FOREVER`** ([lib/queryConfig.ts](./client/src/lib/queryConfig.ts)). Slice subscribers don't register as observers, so without this override TanStack garbage-collects the entry after `GC_TIME.DEFAULT` (5 min) and every consumer reads `undefined`. Symptom: canvas nodes lose their icons + handles after idling on the page. Applies to `fetchNodeSpec`, `fetchNodeGroups`, `useNodeGroups`. The persistor in `lib/queryPersist.ts` only handles cross-reload survival, not in-session GC.

### `nodeStatusStore` for high-frequency state ([client/src/stores/nodeStatusStore.ts](./client/src/stores/nodeStatusStore.ts))
- Per-workflow node statuses live in a Zustand store (built on `useSyncExternalStore`). `useNodeStatus(id)` is a slice selector -- only the affected node's consumers re-render on a status tick.
- Mirror this pattern when adding any new high-frequency push state. Do **not** put it on `WebSocketContext.value` -- that's a context fan-out trap.

### `useAppStore` reads must be slice selectors, never whole-store destructure
- Always `const x = useAppStore((s) => s.x)`, never `const { x } = useAppStore()`. The whole-store form re-renders the consumer on ANY mutation (sidebar toggle, unrelated workflow rename, parameter save on another node), which defeats `React.memo` + `nodePropsEqual` on the canvas. Setters are stable refs from Zustand — single-field selectors are the cheapest read.
- Audited and converted across the canvas + parameter-panel hot paths: every node component, `Dashboard.tsx`, `useDragVariable`, `useParameterPanel`, `useReactFlowNodes`, `useWorkflowManagement`, `InputSection`, `MiddleSection`, `OutputPanel`, `ParameterRenderer`, `ParameterPanel`. New code should follow.

### `isConnected` vs `isReady` -- gate every catalogue/spec query on `isReady` ([WebSocketContext.tsx](./client/src/contexts/WebSocketContext.tsx))
- `isConnected` flips when the socket opens. `isReady` flips immediately after `drainPendingSends(ws)` inside `ws.onopen`; there is no init-burst gate any more (Wave 32).
- The former `Promise.allSettled` init burst and its hardcoded `probeApiKey` loop are gone. Credential state comes from the `initial_status` broadcast and `useCatalogueQuery`'s `provider.stored` flag; terminal / chat / console history restore runs fire-and-forget after `setIsReady(true)`.
- Queries that depend on backend-served catalogue data (`useCatalogueQuery`, `useNodeParamsQuery`, `useUserSettingsQuery`, `useNodeGroups`, `useNodeSpec` lazy fetch, prefetch effect) gate on `isReady` so queued sends replay first and they fire once on the live socket.
- `WebSocketContext.value` is `useMemo`'d -- consumers only re-render when an actual field they read changes. Pending requests are rejected on `ws.onclose` so retries fire immediately on the new socket instead of waiting the 30 s `REQUEST_TIMEOUT`.

### Catalogue invalidation is debounced
- `invalidateCatalogue(queryClient)` in [`hooks/useCatalogueQuery.ts`](./client/src/hooks/useCatalogueQuery.ts) wraps `queryClient.invalidateQueries({ queryKey: CATALOGUE_QUERY_KEY })` with a 300 ms trailing-edge debounce via a single shared module-scope timer. **Always go through it** from broadcast handlers — direct `invalidateQueries` calls were the old pattern.
- The broadcast handlers in `WebSocketContext.tsx` that touch the catalogue (`initial_status` / `full_status`, `api_key_status`, `credential_catalogue_updated`, `plugin_connection_status`, `twitter_oauth_complete`, `google_oauth_complete`, `google_status`) all route through it. An OAuth burst or multi-service reconnect collapses to one refetch instead of N back-to-back round-trips.

### React.memo every canvas node component ([client/src/components/nodeMemoEquality.ts](./client/src/components/nodeMemoEquality.ts))
- React Flow's documented requirement. Use the shared `nodePropsEqual` comparator -- it skips drag-state props (`xPos` / `yPos` / `dragging`) so the memo isn't defeated during drag.
- Applies to `SquareNode`, `AIAgentNode`, `TriggerNode`, `StartNode`. Add new node components the same way.
- Reference: https://reactflow.dev/learn/advanced-use/performance

### Icon + color — per-plugin folder + visuals.json fallback

Plugin icons and colors live co-located in the plugin folder; `visuals.json` is the legacy central registry for plugins that predate it. Resolution chain: **co-located SVG (`icon_<nodeType>.svg` → `icon.svg`) → the plugin's own `meta.json` → `visuals.json`**, documented in [server/nodes/README.md → Icon + color](./server/nodes/README.md) and [docs-internal/plugin_system.md](./docs-internal/plugin_system.md).

**For a node that belongs to a recognisable product, ship the brand mark as an SVG in the plugin folder** — `icon_<nodeType>.svg` per node type, `icon.svg` for the whole folder. This is what makes a node identifiable at canvas size, and it depends on nothing outside the repo.

`meta.json` can also carry icon *references* (`"icons": {"<nodeType>": "lucide:Send"}` per node type, or `"icon"` folder-wide) for generic utility nodes with no brand of their own. Treat that as the lesser option: a `lucide:<Name>` string is a hard dependency on a third-party export that can be renamed or dropped, and when it breaks the node renders **nothing** rather than erroring. Library glyphs are also monochrome `currentColor` line art, so they read as washed-out grey next to brand artwork.

Three traps: a **file always beats a meta.json ref**, so a folder shipping `icon.svg` shadows refs for every node type in it (this is why the four WhatsApp Business nodes once rendered one glyph); `lucide:` names match the package's **export** identifiers, so `lucide:CheckCheck` resolves and kebab-case `lucide:check-check` silently renders nothing; and emoji, while dependency-free, are generic rather than branded. Locked from both sides — `client/src/assets/icons/index.test.ts` walks every `meta.json` under `server/nodes` and asserts each ref resolves to a real component; `test_node_spec.py` asserts every `icons` key is a registered node type.

Backend endpoints serve SVGs at `GET /api/schemas/nodes/<type>/icon` (plugin icons) and `GET /api/schemas/credentials/<provider>/icon` (credential brand icons). Frontend resolver at [client/src/assets/icons/index.ts](./client/src/assets/icons/index.ts) dispatches `lib:brand` / URL passthrough / emoji / `asset:<key>`. The `asset:` branch is currently inert at runtime (the frontend `ICON_REGISTRY` glob finds no SVGs so it resolves to null) but it is NOT dead code — the backend still emits `asset:google` / `asset:stripe` for palette groups (`server/nodes/groups.py`) and the format is invariant-tested on both sides (`test_node_spec.py` Wave 10.B, `icons/index.test.ts`).

**Do not** declare `icon` / `color` as class attributes on a node (the override path was removed in F1). Put `meta.json` in the plugin folder (colour, plus icon refs as above), and `icon.svg` / `icon_<nodeType>.svg` only when artwork is genuinely needed. SKILL.md icon/color resolves from the first node in `allowed-tools` — only orphan skills keep inline `metadata.icon` / `metadata.color`.

## Key Files & Components

### Core Types
- `src/types/INodeProperties.ts` - Core interfaces for n8n-inspired node properties system
- `src/types/NodeTypes.ts` - Legacy compatibility types (NodeParameter, NodeOutput)
- `src/types/workspaceFiles.ts` - Wire types for the gallery / workspace file explorer (`WorkspaceEntry`, `WorkspaceFileRef`, `FILE_REF_KINDS`, `isWorkspaceFileRef`, `WORKSPACE_FILE_DRAG_TYPE`). Declarations only — it documents which decisions are server-owned and must not be re-derived client-side

### Node System
Node metadata is SSOT on the backend after Wave 11. Each node is a Python
plugin at `server/nodes/<category>/<plugin>/__init__.py` that emits a `NodeSpec` via
the registry. The frontend fetches specs through
[`client/src/lib/nodeSpec.ts`](./client/src/lib/nodeSpec.ts) and adapts
them via [`client/src/adapters/nodeSpecToDescription.ts`](./client/src/adapters/nodeSpecToDescription.ts).
See [`docs-internal/plugin_system.md`](./docs-internal/plugin_system.md)
and [`server/nodes/README.md`](./server/nodes/README.md) for the plugin
authoring model.

- `src/lib/nodeSpec.ts` - TanStack-Query-backed spec fetch, `resolveNodeDescription`, `listCachedNodeSpecs`, group lookup
- `src/lib/aiModelProviders.ts` - Frontend-only AI provider icon/credential map
- `src/adapters/nodeSpecToDescription.ts` - Backend `NodeSpec` → legacy `INodeTypeDescription` shape
- `src/services/executionService.ts` - Node execution routed through the backend WebSocket layer

### Assets
- `src/assets/icons/index.ts` - Icon resolver (`lib:brand` / URL / emoji / `asset:<key>` dispatch), drawn by `NodeIcon.tsx`. Brand marks (Google services included) are served from the backend plugin folders via `GET /api/schemas/nodes/<type>/icon`; there is no client-side `google/` icon folder

### UI Components
- `src/components/ParameterRenderer.tsx` - Universal parameter renderer (also handles AI-specific control rendering; the former `AIParameterRenderer.tsx` was absorbed here)
- `src/components/parameterPanel/MiddleSection.tsx` - Parameter panel middle section with conditional display logic
- `src/components/output/OutputPanel.tsx` - Connected node output display with drag mapping
- `src/components/LocationParameterPanel.tsx` - Location-specific parameter handling
- `src/components/parameterPanel/GalleryPanel.tsx` - Workspace file explorer for the `gallery` node (`isGalleryPanel` uiHint); supporting parts in `parameterPanel/gallery/` (`FilePreviewDialog`, `FileGlyph`, `fileIcons`)
- `src/components/AIAgentNode.tsx` - Spec-driven agent canvas component. Reads `useNodeSpec(type)` for handles / icon / colour / displayName / uiHints; renders any plugin whose backend `component_kind` is `"agent"` or `"chat"`. No `AGENT_CONFIGS` map.
- `src/ParameterPanel.tsx` - Main parameter configuration modal

### AI Chat Model Components
AI model nodes route through `SquareNode` via `Dashboard.tsx`'s `COMPONENT_BY_KIND['model']` lookup. Per-provider visual data (icon, color, displayName) comes from the backend `NodeSpec` declared in `server/nodes/model/<provider>_chat_model/__init__.py`. The pre-Wave-11 per-provider wrappers (`BaseChatModelNode`, `OpenAIChatModelNode`, `ClaudeChatModelNode`, `GeminiChatModelNode`, `ModelNode`) were deleted -- nothing imported them after the migration.

### Specialized UI
- `src/components/maps/GoogleMapsPicker.tsx` - Interactive location picker (click / drag marker); wrapped by `maps/MapsPreviewPanel.tsx` and rendered through `parameterPanel/MapsSection.tsx` for `gmaps_create`. Uses Google's default map styling.
- `src/components/output/OutputPanel.tsx` - Execution result display (the active renderer; the legacy `ui/OutputDisplayPanel.tsx` was deleted)
- `src/components/ui/ComponentPalette.tsx` - Searchable component library with emoji icons and dracula-themed category colors. Categories come from the backend node-groups index (`server/nodes/groups.py`); the palette renders them verbatim.
- `src/components/ui/ComponentItem.tsx` - Draggable node items with hover effects and icon rendering
- `src/components/ui/CodeEditor.tsx` - Syntax-highlighted code editor (react-simple-code-editor + prismjs). Token colours come from the per-theme `--code-*` tokens (see [Theme System](./docs-internal/theme_system.md) tier 6) — the code editor, console/output JSON viewers, and chat code blocks all paint in the active theme's syntax palette, not a global dracula scheme. (Retired the old `--prism-*` block + dead `getPrismTokenCSS()`.)

### Hooks & State
- `src/hooks/useParameterPanel.ts` - Parameter management via WebSocket
- `src/services/executionService.ts` - Node execution via WebSocket (`executeNodeViaWebSocket`)
- `src/hooks/useApiKeys.ts` - API key management via WebSocket
- `src/hooks/useWhatsApp.ts` - WhatsApp operations via WebSocket
- `src/hooks/useDragAndDrop.ts` - Drag-and-drop functionality (palette → canvas, `application/reactflow`)
- `src/hooks/useDragWorkspaceFile.ts` - Drag a workspace file onto another node's parameter (`workspaceFile` payload); mirrors `useDragVariable`'s dual-MIME contract — `application/json` for the structured payload, `text/plain` for the bare path
- `src/hooks/useComponentPalette.ts` - Component palette state with localStorage persistence
- `src/store/useAppStore.ts` - Zustand application state with localStorage persistence for UI settings

### Theme System

Two themes — `light` and `dark` (the handoff's ten skins are gone: nine were removed on 2026-10-12 and the last, `atomic`, on 2026-11-09) — driven by `<html data-theme>` (set by [ThemeContext.tsx](./client/src/contexts/ThemeContext.tsx), which also toggles `.dark` for DARK_FAMILY themes) + per-theme CSS in `client/src/themes/`. Home, the editor and the sign-in screen all show the chosen theme, and one theme button ([components/ui/ThemeButton.tsx](./client/src/components/ui/ThemeButton.tsx)) in both modes toggles Light and Dark. Token VALUES are hex + `color-mix()` (never HSL): per-theme files own shadcn/dracula/node/action hex, `base.css` owns the shared `--tint-*` scale, `index.css` is plumbing (`@theme inline` maps `--color-X: var(--X)`). Six token tiers, the `--pulse-keyframe` animation system, the per-component decoration hooks, the 2-pack WebAudio sound system, and the canvas-wide edge/node status rules (`canvasAnimations.ts`) are all documented in **[Theme System](./docs-internal/theme_system.md)** — read it before adding a theme or a canvas-node component. The strict frontend theme RULES remain normative under "Frontend Design + Theme System (strict)" above.

### WebSocket-First Architecture
The project uses WebSocket as the primary communication method between frontend and backend, replacing most REST API calls:
- `src/contexts/WebSocketContext.tsx` - Central WebSocket context with request/response pattern
- `server/routers/websocket.py` - WebSocket endpoint; the live handler set is the `MESSAGE_HANDLERS` dict plus plugin-registered handlers via `services.ws_handler_registry`. Don't hand-maintain a count here.
- `server/services/status_broadcaster.py` - Connection management and broadcasting

**Canvas mutations from the backend** -- any handler that needs to add / move / delete nodes or edges (auto-add-skill on tool connect, Agent Builder runtime tools called by the LLM mid-execution, future workflow-template features) returns a workflow-ops batch (`{operations: [...]}`) and the frontend applies it through `applyOperations` in [client/src/lib/workflowOps.ts](./client/src/lib/workflowOps.ts). Backend builders live in [server/services/workflow_ops.py](./server/services/workflow_ops.py). Two delivery modes: request/response (frontend-driven, e.g. auto-skill) and push broadcast (`workflow_ops_apply`, sent by `services/workflow_ops.broadcast_workflow_ops` and picked up by `useWorkflowOpsListener`). A server-side change to a saved workflow (Turn on Talk, the Agent Builder) goes through `services/workflow_storage/mutate.apply_graph_additions`, which writes the nodes, edges and parameter rows in one transaction and pushes `workflow_ops_apply` with `persisted: true`; editors adopt the server's ids without saving (`useSavedGraphSync` also updates the workflow the app store holds while Home shows). Full spec: [docs-internal/workflow_ops_protocol.md](./docs-internal/workflow_ops_protocol.md).

## Implemented Node Types

> **Authoritative source: backend plugin registry.** Read the live count from `len(services.node_registry.NODE_METADATA)` after `import nodes`; a file glob over [`server/nodes/`](./server/nodes/) miscounts (`**/__init__.py` also matches group packages, and excluding `__init__.py` drops most plugins). The per-node descriptions below are reference material — they drift on every plugin add and should be cross-checked against [`server/nodes/README.md`](./server/nodes/README.md), the per-domain docs in `docs-internal/`, and the actual plugin classes before relying on any specific detail.

### Node Catalogue (collapsed)

> The authoritative, per-node reference is the backend plugin registry plus the per-node "logic-flow" cards under [docs-internal/node-logic-flows/](./docs-internal/node-logic-flows/) (one card per node, grouped by category, with handles / params / outputs / side-effects / edge-cases). Live node list = the plugin registry (one folder per node under `server/nodes/<group>/`; read the total from `len(services.node_registry.NODE_METADATA)` rather than hardcoding it here — a bare `__init__.py` glob overcounts by also matching the group packages). Do NOT maintain a per-node catalogue here — it drifts on every plugin add.

Node groups (palette categories) are registered in [`server/nodes/groups.py`](./server/nodes/groups.py), 34 keys at time of writing: agent, ai, android, api, browser, chat, code, deployment, discord, document, email, filesystem, google, language, location, memory, microsoft, model, payments, proxy, scheduler, scraper, search, service, skill, social, text, tool, trigger, utility, vcs, whatsapp, whatsapp_business, workflow. Folder names are not palette keys: telegram and twitter nodes declare `social`, stripe and dodo_payments declare `payments`, vercel / cloudflare / gcloud / aws declare `deployment`, github declares `vcs`, the `context` node declares `memory`, `vision` declares `tool`, and speech and translate declare `language` (provider-abstracted `textToSpeech` / `speechToText` and `translateText` / `transliterateText` / `detectLanguage`; `nodes/sarvam/` was retired into them, and only `sarvamChatModel` under `nodes/model/` remains vendor-named). See [docs-internal/node-logic-flows/](./docs-internal/node-logic-flows/) for the card index.

## Backend Services

### Python Backend (FastAPI)
- **Port**: `PYTHON_BACKEND_PORT` (defaults in `.env.template`)
- **Base URL**: `http://localhost:${PYTHON_BACKEND_PORT}`
- **Main File**: `server/main.py`

### API Endpoints
#### Android Services (`server/nodes/android/_router.py`, plugin-owned, mounted by the `register_router` loop in `main.py`)
- `GET /api/android/devices` - List connected Android devices via ADB with model and state info
- `POST /api/android/port-forward` - Setup ADB port forwarding for device communication
- `POST /api/android/{service_id}/{action}` - Execute Android service actions with parameters
- `GET /api/android/health` - Android service health check

#### Remote Android WebSocket
- **WebSocket**: Configurable via environment variable - Persistent WebSocket connection for remote Android devices
- **Health Check**: `{relay-url}/ws-health` - WebSocket proxy health status
- **Stats**: `{relay-url}/ws-stats` - Active connection statistics
- **Implementation**: `server/nodes/android/_relay/` (`client.py`, `manager.py`, `broadcaster.py`, `protocol.py`) - Persistent WebSocket relay client with background tasks
  - Background message receiver continuously queues incoming messages
  - Keepalive loop sends ping every 25 seconds to maintain connection
  - Message queue (asyncio.Queue) for async message handling
  - Connection reuse across multiple API requests
  - Message filtering to skip non-response messages (presence, pong, ping)

#### Webhook Router (`server/routers/webhook.py`)
- `ANY /webhook/{path}` - Dynamic webhook endpoint for incoming HTTP requests (GET, POST, PUT, DELETE, PATCH)
- A path a plugin registered with `register_webhook_source` (Stripe, Dodo Payments, WhatsApp Business) goes to that `WebhookSource`; any other path goes to `nodes/trigger/webhook_trigger/_routes.deliver_webhook`, whose route table is the running and paused generations' snapshots: 404 for a path no generation declares, 405 for a method none of its triggers accepts, 401 when the trigger's header check fails (expected value read from the saved node parameters, `hmac.compare_digest`, fail closed). Each matching workflow gets one event scoped to it (`dispatch.emit`, `broadcast=False`), with `authorization`, `cookie`, `proxy-authorization` and the trigger's own header removed. A webhookTrigger with an empty path matches nothing, and Start refuses it (`WEBHOOK_PATH_REQUIRED`)
- A body larger than `WEBHOOK_MAX_BODY_BYTES` (default in `.env.template`) gets 413 before anything is dispatched (`services/events/webhook.read_body_capped`, also used by Discord interactions), because the body rides into a workflow's input
- Returns 200 OK once dispatched (responseNode mode planned for future)
- `GET /webhook/` - Webhook endpoint info and usage documentation

#### Workspace Router (`server/routers/workspace.py`)
- `GET /api/workspace/{workflow_id}/files/{path:path}` - Serve a file from a workflow's workspace.
  Range/seeking comes free from Starlette's `FileResponse` (`206` / `Content-Range` / `If-Range` /
  `416`) — do NOT wrap it in a `StreamingResponse`. Containment via `resolve_within`; 404 never 403
  (a distinct status would confirm what exists outside the workspace). **The `Content-Disposition`
  is not decided here** — it comes from `services.media.preview.serves_inline`, which the gallery
  listing also reads (via `preview_kind`) so the panel never offers a preview the route refuses to
  serve. Only `audio/ image/ video/` render inline; `NEVER_INLINE` is `{image/svg+xml, text/html,
  text/xml, application/xhtml+xml}`, because nodes can write arbitrary files into a workspace and
  inline markup from the app origin is stored XSS.
- **Listing is a WebSocket command, not an HTTP route.** `list_workspace_files` (owned by the
  gallery plugin) is the listing channel; these HTTP routes stay the *content* channel. The
  consumer is the parameter panel, which already holds an authenticated socket with request
  correlation — a second HTTP listing surface would mean a second auth path and a second error
  envelope for no gain.
- `POST /api/workspace/{workflow_id}/uploads` - Streamed multipart upload (the first in the repo on
  either side of the wire). Chunked read with a running total — `Content-Length` is never trusted —
  413 past `MEDIA_MAX_UPLOAD_BYTES`. Owner-only, and never falls back to the shared anonymous
  workspace (both refusals are 404). Returns an `AudioRef` for audio (probed) and otherwise a
  `FileRef` whose kind follows `preview_kind`; `coerce_file_param` accepts both.
- **The URL carries `workflow_id`; the directory is named by `Workflow.slug`.** The router owns that
  lookup because it needs the database, while `services.media` stays synchronous. See
  [Media Transport](./docs-internal/media_transport.md).

#### Workflow Services (`server/services/workflow.py`)
- Node execution dispatches every registered plugin via the `BaseNode` registry (one self-contained folder per node under `server/nodes/<group>/`; live total via `len(services.node_registry.NODE_METADATA)` — do not hardcode it, and note a bare `__init__.py` glob overcounts by also matching the group packages)
- Parameter resolution and template variable substitution
- Result formatting and error handling

#### Frontend-Backend WebSocket (`server/routers/websocket.py`)
- **WebSocket Endpoint**: `/ws/status` - Real-time status updates between React and Python
- **REST Endpoint**: `GET /ws/info` - WebSocket connection info and current status
- **Message Types**:
  - `android_status` - Android device connection status updates
  - `node_status` - Individual node execution status
  - `node_output` - Node execution output data
  - `variable_update` - Single variable value change
  - `variables_update` - Batch variable updates
  - `workflow_status` - Workflow execution progress
  - `ping/pong` - Keep-alive messages

### Development Scripts
Service orchestration lives in the Python CLI (`company dev` / `start` / `stop`, implemented under `cli/commands/`). See [Scripts Reference](./docs-internal/SCRIPTS.md). The former `start.bat` / `stop.bat` / `restart.bat` wrappers and the `concurrently`-based `npm run dev` no longer exist.

### Temporal Distributed Execution

Workflows execute via Temporal for durability and horizontal scaling, gated by `TEMPORAL_ENABLED`. Three original dispatch paths exist — legacy single `execute_node_activity` (fallback), per-type `node.{type}.v{version}` activities (F4.A, `TEMPORAL_PER_TYPE_DISPATCH=true`), and Agent-as-child-workflow (F4.B, `TEMPORAL_AGENT_WORKFLOW_ENABLED=true`) — with `rlm_agent`/`claude_code_agent` always bypassing AgentWorkflow. Newly admitted Coding Agent generations additionally use `PiCodingAgentWorkflow` through their captured Pi harness flag. **Per-queue activity routing is production-default (Wave 16)**: a `TemporalWorkerPool` runs one activity-only worker per plugin-declared `cls.task_queue` (ai-heavy / browser / code-exec / rest-api / messaging / ...) with per-queue concurrency + rate limits + resource-based tuning for ai-heavy/browser; `TEMPORAL_WORKER_POOL_ENABLED=false` only routes later runs' activities to the main worker, and the pool keeps polling. Resilience knobs (Waves 17-18): `DEPLOYMENT_MODE` (local halves concurrency; sizes the sticky cache), unlimited-with-exponential-backoff retry on `agent.execute_llm_step` (`LLM_STEP_RETRY`, `maximum_attempts=0`, 5 s to 5 min; terminal provider categories still fail fast as non-retryable; the earlier one-shot policy was reverted), observability interceptors (`activity_retry` WARN at the first retry, the attempt limit and every 10th attempt after it; an activity still retrying past its limit shows on Home as a `stuck_retry` attention item, and runs whose workflow tasks keep failing as a `stuck_runs` one, `services/attention.py`), 30s periodic heartbeat during long activity bodies, cron `catchup_window=24h`, poller autoscaling. **Conditional edges are evaluated on this path too, as of the `machina-conditional-edges-v1` patch.** They previously were not: `MachinaWorkflow._find_ready_nodes` scheduled purely on dependency completion, so a condition set in the editor rendered an edge label and then did nothing once execution routed through Temporal — silently, with both branches running. The skip mirrors `WorkflowExecutor._evaluate_incoming_conditions` exactly rather than improving on it: **OR-any** across a node's conditional incoming edges, and **skipping is not transitive** (a skipped node counts as completed, matching `get_completed_nodes` including `SKIPPED`, so an unconditional downstream node still runs). `evaluate_condition` is imported from `services.execution.conditions` **directly, never via the package `__init__`**, which also pulls in the executor / cache / recovery sweeper / DLQ; the module itself is pure (`re` + comparisons, no IO, no clock, no randomness) so it is safe to evaluate inside a workflow. The `workflow.patched(...)` gate is mandatory — the skip changes which activities get scheduled, so an ungated change would break replay for in-flight workflows. Locked by `tests/temporal/test_conditional_edges.py`, whose patch-closed case asserts the pre-fix command sequence is still reproduced verbatim.

Execution routing selects Temporal only when the run requests/enables it and its executor is wired; eligible runs can use the sequential path when Temporal is explicitly disabled or that executor is absent. A failure after Temporal dispatch does not replay work locally. Managed employee teams and new Pi Coding Agent runs require Temporal; the Temporal dev server + embedded worker are managed in-process under `server/services/temporal/` (pooch-downloaded CLI, single `temporal server start-dev` process, gRPC `TEMPORAL_FRONTEND_GRPC_PORT` + Web UI `TEMPORAL_UI_PORT`, SQLite at `~/.opencompany/temporal.db`). Full architecture, dispatch matrix, per-node + agent-loop lifecycle, the F4.B `agent.*` activities (unsuffixed since the AgentWorkflow fold; read the live set from `collect_agent_activities()` rather than a hardcoded count), heartbeat semantics, delegation input contract, the worker tuning recipe, and all `.env` tunables live in [docs-internal/TEMPORAL_ARCHITECTURE.md](./docs-internal/TEMPORAL_ARCHITECTURE.md).

**Months-long durability contract (July 2026).** Running and paused deployments survive backend restarts, are never auto-terminated, and keep executing for months. Load-bearing invariants: (1) `TEMPORAL_TERMINATE_RUNNING_ON_STARTUP` stays `false` (debug-only sweep; even enabled, any control row in the shared `WORKFLOW_CONTROL_ACTIVE_STATES` — including `resetting` — vetoes it). (2) New child workflow starts carry **no** `execution_timeout`/`run_timeout` — Temporal's timeout timers keep ticking through a cooperative pause, so lifetime caps silently terminated paused runs; liveness comes from activity heartbeats (30s beat / 2min timeout), never from workflow lifetime caps. Changing an activity's or child's timeouts, retry policy, task queue or parent close policy needs no patch: on replay Temporal compares only a command's kind, its activity or child type, and its id (RFC-0006 §5 rule 6). A cancellation type is the exception when a recorded history can contain a cancel of that activity or child, and any change to which commands run needs a `workflow.patched(...)` guard or, for new generations only, a generation flag frozen at Start (`workflow.GENERATION_FLAGS`, RFC-0005 §24.1 rule 2) (list the live markers with `grep -rn "workflow.patched(" server/services/temporal`; [TEMPORAL_ARCHITECTURE.md](./docs-internal/TEMPORAL_ARCHITECTURE.md) says what each one gates). A new patch id ships dormant for one release: list it in `DORMANT_PATCHES` (`services/temporal/patches.py`; every worker that runs workflows passes its `activate_patch` as `patch_activation_callback`), so that release's runs take the old branch and record no marker, and remove it in the next release (RFC-0006 §18.9 rec 2; a fix for sends, money or approvals may activate at once). (3) Every long-lived workflow loop must continue-as-new under history pressure (`is_continue_as_new_suggested()` + a soft cap) — Temporal hard-terminates around ~51,200 history events; `WorkflowControlWorkflow` carries triggers/queued events/seen-ids/control state across rollovers, so controller handles are addressed **by workflow id only, never run_id-pinned**. (4) `dispatch.emit` skips controllers whose `ControlEventTypes` Search Attribute doesn't match the event (absent attribute = legacy match-all). (5) The Temporal lifecycle is owned by [services/temporal/lifecycle.py](./server/services/temporal/lifecycle.py) (`main.py` only schedules it): dev-server supervision + resident watchdog (`TEMPORAL_HEALTH_MONITOR_INTERVAL_SECONDS`), worker crash-restarts that REBUILD the single-use SDK `Worker` per attempt (manager + every pool queue worker), and the boot-time `reconcile_active_controls_on_boot` pass (converges crash-stranded `starting` rows, runs once more a Reset a crash left `resetting`, re-arms running/paused generations from the persisted graph snapshot). (6) **Recovery policies** (env-driven, defaults in `.env.template`; full semantics in [temporal-workflow-control.md → Recovery policies](./docs-internal/temporal-workflow-control.md)): `WORKFLOW_CONTROL_CRASH_RECOVERY=pause` — after a kill/crash (dirty-bit marker cleared only by graceful teardown) boot recovers running generations as **paused** so the user consciously resumes; `WORKFLOW_CONTROL_MISSING_CONTROLLER=pause` — a killed/vanished controller converges a version 0 generation to paused and **Resume rebuilds the controller** (`WorkflowIdConflictPolicy.USE_EXISTING`, re-arm from snapshot) instead of forcing a state-archiving Reset; a version 1 generation (every new Start) fails closed and needs Reset, then Start; `WORKFLOW_CONTROL_PAUSE_ON_FAILURE=true` — repeatedly-failing trigger-spawned runs pause their deployment via the `workflow_control.pause_on_failure` activity (circuit breaker; trips only after `WORKFLOW_CONTROL_PAUSE_ON_FAILURE_THRESHOLD` (default 3) failed runs, each within `_WINDOW_SECONDS` (default 600) of the one before, so one node hiccup never pauses anything; a success does not reset the streak, Resume and a trip clear it, and the activity runs once so a retry cannot count a run twice; manual canvas runs never qualify). Temporal's native Pause/Unpause (server 1.28+) is deliberately NOT used — no Python SDK surface, and it halts workflow-task dispatch so a natively-paused controller couldn't process the resume Update. (7) **Workflow-scoped event delivery**: an envelope whose `workflow_id` is set by its producer's call site reaches only that workflow's consumers (`dispatch.emit` narrows both Visibility clauses by `EventWorkflowId`); chat is the first scoped producer and delegated-task completion (`nodes/agent/_events.py`) the second, whose broadcast frame carries only the task's identity — the decision lives in `routers/websocket.py`, the narrowing in core dispatch, and plugin `_events.py` factories only plumb the field (see [event_framework.md](./docs-internal/event_framework.md)). (8) **Canvas editability is the server-owned `can_edit` capability** emitted by `serialize_control` (paused = editable; starting/running/transitional = locked); the FE renders it via `lib/canvasLock.ts` + Dashboard's `guardCanvasEdit` and never re-derives the rule from state strings (see [temporal-workflow-control.md → Canvas editability](./docs-internal/temporal-workflow-control.md)). Locked by `tests/temporal/test_durability_hardening.py` + `test_worker_restart.py`.

**Temporal tracing ownership is strict.** Register the SDK `TracingInterceptor` exactly once on the shared `Client.connect(...)`; the Python SDK automatically prepends compatible client interceptors to every worker. Never repeat `TracingInterceptor` in `Worker(..., interceptors=...)`. Worker lists contain the distinct `ObservabilityWorkerInterceptor` and plugin-specific worker interceptors only. Exact paired `StartActivity` / `RunActivity` / `CompleteWorkflow` console spans with identical Temporal identities indicate duplicate instrumentation, not duplicate execution; use Temporal Event History and the `node.<type>.execute` application span to verify actual attempts. See the tracing and debugging sections in [TEMPORAL_ARCHITECTURE.md](./docs-internal/TEMPORAL_ARCHITECTURE.md) and [errors.md](./docs-internal/errors.md).

## Development Commands

**Package manager and JavaScript runtime: bun** (pinned via `packageManager: bun@1.4.0` in the root package.json; `engines` is `{"bun": ">=1.4.0"}`). Source checkouts are gated to bun by `scripts/preinstall.js` (keys on `bunfig.toml` + a `bun` user agent) — `npm install` in the repo is rejected with a pointer to https://bun.sh. `bunfig.toml` pins the **isolated linker** (pnpm-style symlinked `node_modules` with the store at `node_modules/.bun/`) and the lockfile is the tracked text `bun.lock`. bun is also the only JavaScript runtime anything shipped needs: the `company` shim (`bin/cli.js`, `#!/usr/bin/env bun`), the JS executor sidecar (`bun build --target=bun` bundle run as `bun dist/index.js`) and the plugin CLIs in the shared `~/.opencompany/packages/` tree all run on it (`server/core/js_runtime.py`; `tests/core_config/test_js_runtime.py::test_backend_never_spawns_node_or_npm` forbids `shutil.which("node"|"npm"|"npx")` under `server/`), end users install with `bun add -g @zeenie-ai/opencompany`, and the desktop app bundles bun instead of Node. Node is dev/CI-only: when it is present bun runs vite / vitest / eslint / playwright / electron-builder on it via their node shebangs (vitest and eslint have open bugs on the bun runtime), so `--bun` is never passed; `company build` treats Node as optional and `.github/actions/setup` installs Node 22 for that reason alone. Security pins live in the top-level `overrides` block (version-ranged keys). See [CONTRIBUTING.md](./CONTRIBUTING.md) → "Known differences from pnpm" for the guards that changed in the 2026-08 pnpm→bun migration.

### CLI Commands (after the global install)
```bash
company start        # Start all services (production mode)
company dev          # Start all services in dev mode (Vite HMR)
company dev --force  # ...forcing Vite to re-bundle deps (recovers "Outdated Optimize Dep"; sets VITE_FORCE -> optimizeDeps.force — the dep cache is otherwise preserved across boots)
company stop         # Stop all services
company build        # Build for production
company clean        # Clean build artifacts
company provision    # Set up the Python side of a global install (automatic on first use; --force redoes it)
company help         # Show all commands
```

### package.json scripts (run with bun)
```bash
# Core (thin wrappers over the Python CLI)
bun run start            # Start all services (python -m cli start)
bun run stop             # Stop all services (python -m cli stop)
bun run build            # Build for production (python -m cli build)
bun run clean            # Clean build artifacts (python -m cli clean)
bun run deploy           # Self-deploy to a cloud VM (python -m cli deploy)
```
No root script hops through `npm run`; cross-package scripts use `bun --cwd=<dir> run <script>`.

### Cross-Platform Scripts
Service orchestration lives in the Python CLI (`company start/dev/stop/build/clean/serve/daemon/deploy/docs/version` — see `cli/`). The `scripts/` directory retains only the install lifecycle helpers (`install.js`, `preinstall.js`, `postinstall.js` — `#!/usr/bin/env bun`; run by `bun install` in a checkout, and eagerly by the installers or lazily by `bin/cli.js` on the first `company` command of a `bun add -g` install, which runs no dependency lifecycle scripts). `company start` is single-port: uvicorn serves API + WS + built SPA on the backend port (`SERVE_STATIC_CLIENT`, default on); the retired `scripts/serve-client.js` static server and its `:3000` frontend port are gone. <!-- docs-lint: ignore; retired paths are recorded here as migration history. -->

See **[Scripts Reference](./docs-internal/SCRIPTS.md)** for full documentation.

## Current Status
✅ **Plugin-first architecture (Wave 11)**: every plugin is a self-contained folder under `server/nodes/<group>/<plugin>/` rooted at `__init__.py`; backend NodeSpec is the SSOT for icon, colour, handles, params, output schema, uiHints. Frontend renders via `useNodeSpec` + `componentKind` dispatch.
✅ **WebSocket-First Architecture**: most frontend-backend RPC goes through WebSocket; live handler set lives in `MESSAGE_HANDLERS` in `server/routers/websocket.py`
✅ **Code Editor**: Python, JavaScript, and TypeScript executors with syntax-highlighted editor (react-simple-code-editor + prismjs) and console output
✅ **JS Executor Sidecar**: Persistent Express server on bun (`bun build --target=bun` bundle, no Node or npm anywhere shipped) for fast JS/TS execution, replacing subprocess spawning
✅ **Component Palette**: Emoji icons with distinct dracula-themed category colors, localStorage persistence for collapsed sections
✅ **Android Integration**: 16 Android service nodes with ADB automation and remote WebSocket support
✅ **Conditional Parameter Display**: Dynamic UI rendering based on parameter values (displayOptions.show)
✅ **Execution Engine**: Full component execution with result display
✅ **Parameter Mapping**: Drag-and-drop output to parameter connections
✅ **AI Integration**: API key management and model selection
✅ **Location Services**: Interactive map picker with coordinate handling, Google Maps API key fetched from backend credentials
✅ **Code Cleanup**: Dead code removed, unused files deleted
✅ **Process Management**: Python CLI supervisor (`cli/supervisor.py`) with Job Object / process-group tree kill and port-based `company stop`
✅ **WhatsApp Integration**: Square node design with QR code viewer, group/sender name persistence, newsletter channel support (send, query, follow/unfollow, create, mute, mark viewed, react, live updates), media download, profile pics, and proper error handling
✅ **Backend Stability**: Fixed dependency injection and error handling preventing crashes
✅ **Development Server**: the app URL is the same in every mode, **`http://localhost:${VITE_CLIENT_PORT}`**; `.env.template` sets `VITE_CLIENT_PORT` equal to `PYTHON_BACKEND_PORT` (locked by `cli/tests/test_env_ports.py`). `company dev` = Vite HMR on `VITE_CLIENT_PORT`, proxying /api /ws /webhook to the backend on the `PYTHON_BACKEND_PORT` that `.env.dev` overrides; `company start` (production) = uvicorn alone on `PYTHON_BACKEND_PORT` (API + WS + SPA)
✅ **WebSocket Integration**: Persistent WebSocket connections for remote Android devices with background tasks and message queue
✅ **Real-time Status WebSocket**: Frontend-backend WebSocket at `/ws/status` for live Android status, node status, and variable updates
✅ **Event-Driven Trigger Nodes**: WhatsApp Receive and Webhook Trigger with asyncio.Future-based event waiting, filter builders, and cancel support
✅ **Continuous Scheduling Execution**: Temporal/Conductor pattern using `asyncio.wait(FIRST_COMPLETED)` for true parallel pipelines where dependent nodes start immediately when their specific dependency completes
✅ **Event-Driven Deployment**: n8n-style architecture where each trigger event spawns an independent, concurrent execution run (no iteration loop)
✅ **HTTP/Webhook Nodes**: HTTP Request for external APIs, Webhook Trigger for incoming requests, Webhook Response for custom responses
✅ **Theme System**: Neutral-slate (dark) + grey-blue paper (light) surfaces with Dracula accent palette, dark mode support, neutral controls with one `invert` primary per screen (colour marks state or role; the tinted action buttons remain only in the editor's panels), and themed React Flow edges
✅ **Modular Backend Architecture**: workflow.py refactored from 2068 lines into a facade (~840 lines today) over NodeExecutor, ParameterResolver, and DeploymentManager modules
✅ **Node Rename System**: n8n-style node renaming via F2 keyboard shortcut, double-click on label, or right-click context menu with inline editing
✅ **UI State Persistence**: localStorage persistence for sidebar visibility, component palette visibility, dev mode, and collapsed sections
✅ **Normal/Dev Mode**: Normal mode (Home: hire and supervise AI employees) is the landing screen; the Normal/Dev switch in the editor toolbar and the Home header (or Ctrl/Cmd+Shift+D) moves to Dev mode, the workflow editor. `VITE_NORMAL_MODE=false` restores the old palette-filter toggle
✅ **Production Deployment**: `company deploy` provisions a login-gated VM via Terraform + cloud-init running `company serve` under systemd; `docker compose up -d --build` runs a one-container self-hosting image built from source (see [docker.md](./docs-internal/docker.md)); the old 4-container Docker Compose topology was removed (see [deployment_legacy.md](./docs-internal/deployment_legacy.md))
✅ **Authentication System**: n8n-style JWT authentication with HttpOnly cookies, single-owner and multi-user modes, rate-limited login. Note `AUTH_MODE=multi` authenticates but does NOT isolate data — see Known Limitations in [authentication.md](./docs-internal/authentication.md)
✅ **Cache System**: Redis is optional and default-off; the shipped local path uses database-backed caching with an emergency in-memory fallback
✅ **AI Thinking/Reasoning**: Extended thinking for Claude, Gemini 2.5/3, OpenAI GPT-5/GPT-6/o-series, Groq Qwen3 with output available in Input Data & Variables for downstream nodes
✅ **Onboarding Service**: 3-step Welcome guide on Home with shadcn UI, database persistence, skip/resume/replay support; the Get started checklist (a hire-view card, a header pill on employee pages) and Home's AI models dialog
✅ **Proxy System**: Residential proxy provider management with template-based URL formatting, auto-selection by health score, transparent proxy injection on httpRequest/httpScraper nodes via `useProxy: true`
✅ **Markdown Formatter**: GFM markdown to platform-native formatting (Telegram HTML, WhatsApp syntax, plain text) using markdown-it-py

## Key Features

### Parameter System
- **Universal Renderer**: Supports both INodeProperties and NodeParameter interfaces
- **Type-Specific Controls**: String, number, boolean, select, slider, file, array types
- **Drag-and-Drop**: Map outputs from connected nodes to parameters
- **Validation**: Required field checking and type constraints
- **Conditional Display**: Dynamic parameter visibility using displayOptions.show pattern
  - Implemented by `shouldShowParameter()` in `client/src/utils/parameterVisibility.ts` (imported by `MiddleSection.tsx`)
  - Supports array-based conditions (e.g., `messageType: ['text']`)
  - Filters parameters before rendering based on other parameter values

### Node Rename System (n8n-style)
Three methods for renaming nodes, following n8n UX patterns:
- **F2 Keyboard Shortcut**: Press F2 with a node selected to enter rename mode
- **Double-click on Label**: Click the node label twice to edit inline
- **Right-click Context Menu**: "Rename" option in the context menu

All three are canvas edits and obey the canvas lock: F2 and the menu call Dashboard's `guardCanvasEdit`, and `EditableNodeLabel` reads the same guard through `contexts/canvasEditGuard.ts`, which Dashboard provides around `<ReactFlow>`.

#### Architecture
```
Global State (useAppStore)          Node Components
├── renamingNodeId: string | null   ├── SquareNode.tsx
├── setRenamingNodeId()             ├── TriggerNode.tsx
        ↓                           └── StartNode.tsx
   Coordinates which node               ↓
   is currently being renamed       Local State:
                                    ├── isRenaming: boolean
                                    ├── editLabel: string
                                    └── inputRef: HTMLInputElement
```

#### Implementation Files
- **`client/src/store/useAppStore.ts`** - Global rename state (`renamingNodeId`, `setRenamingNodeId`)
- **`client/src/components/ui/NodeContextMenu.tsx`** - Right-click menu with Rename, Copy, Delete
- **`client/src/Dashboard.tsx`** - Context menu handler, F2 keyboard handler
- **`client/src/components/ui/EditableNodeLabel.tsx`** - the single inline-rename component; every canvas node renders it.

#### Key Pattern (EditableNodeLabel.tsx)
```typescript
// Sync with global rename state.
useEffect(() => {
  if (renamingNodeId === nodeId) {
    setIsRenaming(true);
    setEditLabel(label || defaultLabel);
  } else {
    setIsRenaming(false);
  }
}, [renamingNodeId, nodeId, label, defaultLabel]);

// Handle save - only save if changed and non-empty
const handleSave = useCallback(() => {
  const newLabel = editLabel.trim();
  const original = label || defaultLabel;
  if (newLabel && newLabel !== original) {
    onLabelChange(newLabel);
  }
  setIsRenaming(false);
  setRenamingNodeId(null);
}, [editLabel, label, defaultLabel, onLabelChange, setRenamingNodeId]);
```

#### NodeContextMenu Features
- Rename (F2), Copy (Ctrl+C), Delete (Del) with keyboard shortcuts shown
- Uses existing `useCopyPaste.copySelectedNodes()` for Copy
- Delete goes through `useReactFlowNodes.deleteNodeById` (React Flow's `deleteElements`), the same path as the Delete key, so the node's edges are removed too and `onEdgesDelete` reports them
- Keyboard navigation (Arrow keys, Enter)
- Click outside to close
- Tailwind semantic tokens (no palette names)

### UI State Persistence
The application persists UI state to localStorage for a consistent user experience across sessions:

#### Persisted Settings
| Setting | Storage Key | Default | Location |
|---------|-------------|---------|----------|
| Sidebar visibility | `ui_sidebar_visible` | `true` | `useAppStore.ts` |
| Component palette visibility | `ui_component_palette_visible` | `true` | `useAppStore.ts` |
| Shell mode (Normal / Dev screen) | `ui_shell_mode` | `normal` (`dev` when `ui_pro_mode` is `'true'`) | `useAppStore.ts` |
| Palette filter (only with `VITE_NORMAL_MODE=false`) | `ui_pro_mode` | `false` | `useAppStore.ts` |
| Collapsed palette sections | `component_palette_collapsed_sections` | All collapsed | `useComponentPalette.ts` |

#### Implementation Pattern
```typescript
// In useAppStore.ts
const STORAGE_KEYS = {
  sidebarVisible: 'ui_sidebar_visible',
  componentPaletteVisible: 'ui_component_palette_visible',
};

const loadBooleanFromStorage = (key: string, defaultValue: boolean): boolean => {
  try {
    const saved = localStorage.getItem(key);
    if (saved !== null) return saved === 'true';
  } catch { /* Ignore storage errors */ }
  return defaultValue;
};

// Initial state loads from localStorage
sidebarVisible: loadBooleanFromStorage(STORAGE_KEYS.sidebarVisible, true),

// Toggle functions save to localStorage
toggleSidebar: () => {
  set((state) => {
    const newValue = !state.sidebarVisible;
    saveBooleanToStorage(STORAGE_KEYS.sidebarVisible, newValue);
    return { sidebarVisible: newValue };
  });
},
```

### Normal/Dev Mode Toggle
The app has two screens, and a Normal / Dev switch moves between them:

| Mode | Screen |
|------|--------|
| **Normal** (default) | Home: hire AI employees by describing a job, and supervise them ([client/src/features/home/](./client/src/features/home/)) |
| **Dev** | The workflow editor (`Dashboard.tsx`); its palette lists every node the blocklists allow |

#### Implementation
- **State**: `shellMode` (`'normal' | 'dev'`) in `useAppStore.ts`, persisted as `ui_shell_mode`. While that key is unset it comes from the old palette choice: `ui_pro_mode === 'true'` starts in Dev, anything else in Normal.
- **Switching**: [`components/shell/ModeToggle.tsx`](./client/src/components/shell/ModeToggle.tsx) (editor toolbar and Home header) and Ctrl/Cmd+Shift+D (`app/useModeShortcut.ts`) call `enterNormal` / `enterDev` from [`app/useShellActions.ts`](./client/src/app/useShellActions.ts), never `setShellMode` directly: those guard unsaved editor work, preload the editor chunk, and run the transition (`app/shellTransition.ts`). [`app/ShellModeSwitch.tsx`](./client/src/app/ShellModeSwitch.tsx) renders the current screen; both screens are lazy chunks, so ReactFlow stays out of a Normal-mode session. Locked end to end by `app/__tests__/modeSwitch.test.tsx`.
- **Kill switch**: `VITE_NORMAL_MODE=false` ([`lib/featureFlags.ts`](./client/src/lib/featureFlags.ts)) turns Normal mode off. The app then opens straight into the editor and the toolbar switch goes back to filtering the palette with `proMode` (`ui_pro_mode`): in its Normal setting a node is hidden unless its first group's `groups.py` `visibility` is `'normal'` or `'all'`, and `enabled_nodes` applies.
- **Allowlist**: with Normal mode on, `node_allowlist.json` `enabled_nodes` governs what Hire may build instead of filtering the editor palette (see [Node Allowlist](./docs-internal/node_allowlist.md)).
- **Themes**: Home and the editor show the chosen theme, Light or Dark (a third, Atomic, was removed on 2026-11-09). The theme button ([`components/ui/ThemeButton.tsx`](./client/src/components/ui/ThemeButton.tsx)) in the Home header, the editor's toolbar and the sign-in screen toggles between them, and Home Settings → Profile → Appearance offers the same two.

### Console Panel
The Console Panel provides a resizable bottom panel with three sections: Chat (AI conversation), Console (node execution logs), and Terminal (server logs). **Hybrid layout (design-handoff)**: split view (default) docks Chat as a resizable pane beside the Console/Terminal tabs so chat and logs stay simultaneously visible during agent runs; tab mode makes Chat the first of three tabs. Toggled via the Columns2 button in the tab row; persisted in `consolePrefs.splitView` (default `true` — existing users see no change). Both layouts render one `ConsoleChat` ([components/ui/ConsoleChat.tsx](./client/src/components/ui/ConsoleChat.tsx)): the shared chat ([features/chat](./client/src/features/chat/), the same `ChatPane` as Home's employee page) in its compact form, over the open workflow's chat session showing the live generation only. Its turns carry the per-theme `chat-msg*` hooks, and the console's font size applies to the conversation.

#### Features
- **Resizable**: Drag handle at top to resize (vertical) + chat-pane divider (horizontal, split view), persisted to localStorage
- **Hybrid Chat placement**: docked split pane (default) or first tab — `consolePrefs.splitView`
- **Tabs**: Chat (tab mode only) / Console / Terminal
- **Chat Section**: Send messages to the workflow's chat triggers and follow the runs they start (Working… until the run ends, failures inline; [Chat Protocol](./docs-internal/chat_protocol.md#client)); Clear clears the chat and what its agents remember of it; `chatFocusRequest` switches to the Chat tab before focusing the message box (through the pane's `focusComposer` handle) when in tab mode
- **Console Section**: View and filter node execution logs

#### Console Selector
When the workflow has several console nodes, a dropdown in the Console section's controls filters the logs to one of them (`isConsoleSink` uiHint, with a type-string fallback). There is no chat-trigger selector: a message goes to every chat trigger of the open workflow whose filter accepts the session.

**Implementation** (`client/src/components/ui/ConsolePanel.tsx`):
```typescript
const consoleNodes = useMemo(() =>
  nodes.filter(n => {
    const def = n.type ? resolveNodeDescription(n.type) : undefined;
    return def?.uiHints?.isConsoleSink
      ?? (n.type ? ['console'].includes(n.type) : false);
  }),
  [nodes]
);
const [selectedConsoleId, setSelectedConsoleId] = useState<string>('');
```

#### Chat Message Persistence
Chat messages are persisted to SQLite database and survive server restarts. A workflow's session id is its workflow id (`"default"` with no workflow open), and Home shows the same thread as Talk on the employee's page. Rows are written through [`services/chat_thread.py`](./server/services/chat_thread.py) (`record_chat_message` / `clear_chat_thread`) and, for the owner's messages and the chat runs they start, [`services/chat/ledger.py`](./server/services/chat/ledger.py); both stamp the session's live generation, append the row after the session's active leaf (one chain, `chat_threads`), and broadcast the identity-only `chat.updated`; agents answer into the thread through the `chatReply` ("Reply in Chat") node, as the run's reply when a chat run started it. Runs, their events and the wire contract are in [Chat Protocol](./docs-internal/chat_protocol.md). The thread lives as long as the generation: a workflow Reset (every restart, Home's Apply and Turn on Talk included) clears it through the `chatTrigger` / `chatReply` `reset_execution_state` hook, in the same Reset in which the Context node forgets the conversation, and deleting the workflow deletes it (a workflow-deleted hook the `chatReply` plugin registers).

**Database Model** (`server/models/database.py`):
```python
class ChatMessage(SQLModel, table=True):
    __tablename__ = "chat_messages"
    id: Optional[int] = Field(default=None, primary_key=True)
    session_id: str = Field(default="default", index=True, max_length=255)
    execution_id: Optional[str]  # the generation (root_execution_id) it was written in
    role: str = Field(max_length=20)  # 'user' or 'assistant'
    message: str = Field(max_length=50000)
    uid: Optional[str]         # stable wire id: m_... (owner), a_<run id> (a run's reply); unique
    parent_uid: Optional[str]  # the message it follows (branches link here)
    run_id: Optional[str]      # the chat run it started or answers (models/chat.py)
    kind: str                  # text / report / action / notice
    status: str                # complete / stopped / error
    parts: Dict; attachments: list; meta: Dict  # JSON
    created_at: datetime
```

**WebSocket Handlers** (`server/services/chat/handlers.py`, registered by `services/chat/__init__.py`):
| Handler | Description |
|---------|-------------|
| `send_chat_message` | Save the message and dispatch it to the workflow's chatTriggers. A workflow session answers `delivery: "now"` (running / starting / resuming) or `"queued"` (paused / pausing, runs on Resume); in any other state `not_running`, saving nothing |
| `get_chat_messages` | The path shown in a session, oldest first: `{id, role, message, timestamp, run_key, siblings, editable, feedback, ...}` (UTC-offset timestamps; `run_key` = the generation; `siblings` = the message's versions). The latest generation only (none after a Reset), unless `all_generations` (Home's thread; a Reset clears the thread, so only a row from before a Start shows beside the live ones) |
| `clear_chat_messages` | Clear a session's chat, every generation; for a workflow's session its agents forget the conversation too (`chat_thread.clear_chat_session` tells the listeners registered with `register_chat_cleared_listener`: the Context plugin clears the workflow's conversations) |
| `save_chat_message` | Save one message with the given role |
| `chat_subscribe` / `chat_unsubscribe` | Start / stop receiving a session's `chat_run_event` frames; subscribe answers the live runs' snapshots |
| `get_chat_run` | One run's snapshot |
| `edit_chat_message` / `regenerate_chat_reply` / `switch_chat_branch` | Edit one of the owner's messages, try the latest answer again, or show another version; the agent's memory follows the path (see [Chat Protocol → Branches](./docs-internal/chat_protocol.md#branches)) |
| `set_chat_feedback` | Rate an answer `up` / `down` (or take it back); the employee reads it on its next turn |
| `get_chat_context` | What the message box offers: slash commands, whether files can be attached, whether Web has search to turn off, whether the employee takes a model choice (`model_choice`), the limits |
| `get_chat_models` | Home's model picker for one employee: Auto (the owner's default model, else the employee's own), the offered models of connected providers and the thinking levels; the owner's choice is in their settings (see [Chat Protocol → Model and thinking](./docs-internal/chat_protocol.md#model-and-thinking)) |

Every chat command refuses the internal worker socket, and a workflow's session answers only the workflow's owner (`services/chat/access.py`).

**Database Methods** (`server/core/database.py`):
- `add_chat_message(session_id, role, message, execution_id=None, *, uid=None, run_id=None, ...)` - Append a message after the session's active leaf; returns the row (a `uid` already saved returns that row)
- `append_chat_row(session, ...)` - The same, inside a caller's `reserved_session()` (the run ledger admits a message and its run in one transaction)
- `read_chat_messages(session_id, limit=None, execution_id=None)` - Newest `limit` messages, oldest first, optionally of one generation; raises when the read fails, so a failure never reads as an empty thread
- `clear_chat_messages(session_id)` - Delete all messages for session, with its runs, run parts and thread state

#### Console Log Persistence
Console logs are persisted to SQLite database and loaded on page refresh.

**WebSocket Handlers**:
| Handler | Description |
|---------|-------------|
| `get_console_logs` | Retrieve console logs from database (limit: 100) |
| `clear_console_logs` | Clear all console logs from database |

**Database Methods** (`server/core/database.py`):
- `add_console_log(log_data)` - Add console log to database
- `get_console_logs(limit)` - Get console logs
- `clear_console_logs()` - Delete all console logs

#### Key Files
| File | Description |
|------|-------------|
| `client/src/components/ui/ConsolePanel.tsx` | Main panel component with chat/console/terminal tabs |
| `client/src/components/ui/ConsoleChat.tsx` | The Chat pane: the shared chat (`features/chat`), compact, over the open workflow's session |
| `server/models/database.py` | ChatMessage and ConsoleLog SQLModel definitions |
| `server/core/database.py` | Chat message and console log CRUD methods |
| `server/routers/websocket.py` | WebSocket handlers for console operations |
| `server/services/chat/` | Chat runs, run events and the chat WebSocket handlers ([Chat Protocol](./docs-internal/chat_protocol.md)) |

### Per-Workflow Workspace Directory
Each workflow execution gets a persistent workspace directory where nodes save output files and agents such as Coding Agent access them through connected filesystem tools.

**Directory**: `~/.opencompany/workspaces/<workflow_slug>/` (Wave 14 — keyed by the human-readable slug, not the UUID; see "Workflow naming" below).

**Configuration** (`server/core/config.py`):
```python
workspace_base_dir: str = Field(default="workspaces", env="WORKSPACE_BASE_DIR")  # resolved under DATA_DIR -> ~/.opencompany/workspaces/
```

**How it works:**
- `workflow.py` creates the workspace dir and injects `workspace_dir` into the execution context. The dir name is the `workflow_slug` resolved from the DB (falls back to `"default"` for one-off Runs without a saved row).
- `fileDownloader` saves to `{workspace_dir}/downloads/` by default
- Code executors (Python/JS/TS) receive `workspace_dir` in their execution namespace
- The `fileRead`, `fileModify`, `fsSearch`, `shell` and `gallery` nodes use the native `WorkspaceBackend` rooted at `workspace_dir`; Coding Agent can invoke the first four when they are connected as tools. **`gallery` is deliberately `usable_as_tool = False`** — it carries destructive operations, and `fsSearch` + `fileModify` already cover what an agent needs, so shipping an editor panel must not hand every agent a delete tool as a side effect
- `WorkspaceBackend` resolves and validates paths beneath that root, rejects traversal and symlink escapes, and uses per-path locks plus atomic replacement for writes
- Rename follows the workflow: when the user renames a workflow, `save_workflow` recomputes the slug and `os.rename`s the workspace dir to match (existing files preserved).

**Key Files:**
| File | Description |
|------|-------------|
| `server/core/config.py` | `workspace_base_dir` setting |
| `server/services/workflow.py` | `_get_workspace_dir()`, injects into context |
| `server/nodes/document/file_downloader/` | `fileDownloader` saves to the workspace |
| `server/nodes/code/` | `workspace_dir` available in Python/JS/TS executors |
| `server/nodes/filesystem/_backend.py` | Native contained workspace filesystem implementation |

### Workflow Naming (Wave 14)
The workflow record carries three identity fields with strict separation:

| Field | Carrier | Stable? | Surfaces |
|---|---|---|---|
| `Workflow.id` | opaque 32-hex UUID (`uuid.uuid4().hex`) | yes — never changes on rename | FK target (`Execution.workflow_id`), `EventWorkflowId` Search Attribute in Temporal Visibility, legacy `WorkflowEvent.workflow_id` compatibility field, `log_context(workflow_id=...)`, Redis cache keys, `DeploymentManager._deployments` dict key, frontend `useAppStore.currentWorkflow.id` |
| `Workflow.name` | free-form display ("AI Assistant") | mutable | sidebar, parameter panel, exported JSON |
| `Workflow.slug` | `<Sanitized_Name>_<N>` (`AI_Assistant_1`) | mutable, recomputed on rename | `~/.opencompany/workspaces/<slug>/`, Temporal workflow IDs (visible in Temporal Web UI), cron Schedule IDs, export filenames |

Single source of truth: [`server/services/workflow_naming.py`](./server/services/workflow_naming.py) — `slugify_name` (via `python-slugify` for Unicode transliteration, emoji strip, case preservation, length cap), `next_available_slug(name, database, *, exclude_id=None)` (fill-gap counter; pass `exclude_id=workflow_id` on rename so the row doesn't bump itself), `canonicalize_node_ids(workflow_id, nodes, edges)` (pure, idempotent: gives node instances stable `<workflow_id>:<type>:<ordinal>`-style ids derived from plugin type + per-type ordinal, returning the rewritten nodes/edges plus the old-to-new mapping), `node_label_slug(node)` (sandbox-safe stdlib slug from `node.data.label` or `node.type`, used inside Temporal `@workflow.defn` modules where `python-slugify` can't import safely).

**Temporal workflow ID convention** — ids for trigger listeners, their runs, cron Schedules and direct runs start with the workflow slug (shapes below; most are `<workflow_slug>-<node_label>`). The Temporal Web UI's "Workflow Type" column already distinguishes those kinds (TriggerListenerWorkflow / PollingTriggerWorkflow / CronTriggerWorkflow / MachinaWorkflow), so their ids carry no middle `-trigger-` tag. Other framework workflows are keyed by immutable ids instead: agent children (row below), delegated-task runners (`_delegation_child_id` in `services/temporal/agent_workflow.py`), the per-generation controller (`workflow-control-<workflow_id>-g<generation>`, `services/deployment/control.py`) and direct Workspace tasks (`services/node_invocations.py`).

| Surface | Format | Example |
|---|---|---|
| Trigger listener (push/poll) | `<slug>-<trigger_label>` | `AI_Assistant_1-chatTrigger` (or `AI_Assistant_1-Customer_Inbox` after F2 rename) |
| Per-firing run (child of listener) | `<slug>-<trigger_label>-<event_id>` | `AI_Assistant_1-chatTrigger-evt-abc` |
| Cron Schedule | `<slug>-<trigger_label>` | `AI_Assistant_1-cronScheduler` |
| Cron firing (per-tick child) | `<slug>-<trigger_label>-<ScheduledStartTime>` | `AI_Assistant_1-cronScheduler-2026-05-27T12:00:00Z` |
| Agent child workflow | `<root_temporal_workflow_id>-agent-<node_id>` | `AI_Assistant_1-chatTrigger-evt-abc-agent-<workflow_id>:aiAgent:1`. `_agent_child_workflow_id` (`services/temporal/workflow.py`) builds it from the root run's Temporal id and the immutable canvas node id, because labels change and need not be unique. It is not patch-gated, and there is no label-derived form. |
| Direct MachinaWorkflow exec | `<slug>-<uuid8>` | `AI_Assistant_1-a1b2c3d4` |
| Per-node activity (inside MachinaWorkflow) | `activity_id = <node_id>` | `<workflow_id>:chatTrigger:1` (node ids are canonical `<workflow_id>:<type>:<ordinal>`, see `canonicalize_node_ids`) |

`node_label` is `node.data.label` (the F2-renamed canvas label) when set, falling back to `node.type` (`chatTrigger` / `telegramReceive` / `aiAgent` / etc.). Computed once at deploy time via `node_label_slug(node)` and passed into Temporal workflows as `trigger_label` in `listener_data`; agent child ids use the node id, not the label. Stable `TriggerNodeId` / `EventWorkflowId` Search Attributes still use the immutable node_id / UUID so admin queries don't break across rename.

**Rename path** — the editor has NO dedicated rename endpoint: its auto-save chain (`TopToolbar` inline edit → `updateWorkflow({name})` → debounced save → REST `POST /api/database/workflows` → `services.workflow_storage.handlers.handle_save_workflow`) IS its rename path. Home's `rename_employee` renames through `rename_saved_workflow` in the same module, without touching the graph. When `name` changes, either path (1) allocates a fresh slug via `next_available_slug`, (2) `database.rename_workflow` updates name + slug atomically (id UUID stays put), (3) renames the on-disk workspace dir via `Path.rename()`, (4) broadcasts a CloudEvents `workflow.renamed` envelope (`broadcaster.broadcast_workflow_lifecycle("renamed", workflow_id=..., name=..., slug=..., old_slug=...)`) so other tabs invalidate their workflows query. A rename leaves a running generation's Temporal ids alone: its listeners and cron Schedules keep the slug its Start recorded in the control row's `graph_snapshot` (`workflow_slug`, read by `DeploymentManager._generation_slug`), so the re-arm after a restart registers the same ids instead of a second set beside them, which made every event start two runs. The next Start names them by the new slug.

**Invariants** (locked by `tests/services/test_workflow_naming.py` + `test_workflow_rename.py` — 39 tests):
- First creation always gets `_1` suffix (no bare-base slugs).
- Fill-gap: deleted `AI_Assistant_2` slot is reused on next "AI Assistant" creation, unless a live generation of another saved workflow still names its Temporal ids by it (a workflow renamed while it runs keeps the slug its Start recorded, and `list_workflow_slugs` returns those too). A cron Schedule left by a deleted workflow under a reused slug is replaced at Start (`create_cron_schedule(owner_gone=...)`), not reported as `cron_schedule_ownership_conflict`.
- Renaming `AI Assistant` → `AI Assistant!` (same slug base) keeps `_1` via `exclude_id` (no self-bump).
- UNIQUE constraint on `slug` is the final collision guard; `IntegrityError` indicates a race the caller should retry.
- Non-ASCII names transliterate via `text-unidecode` ("日本語" → "Ri_Ben_Yu"); fall back to `Workflow_N` only when slug is empty after sanitize.
- No backfill migration — the slug column is required on every save. Existing DBs must be rebuilt.

### Execution System
- **Supported Components**: AI models, location services, Android automation, WhatsApp messaging, HTTP requests, webhooks
- **Android Integration**: ADB-based device control with 16 service nodes across monitoring, apps, automation, sensors, and media
- **Result Display**: Formatted output panel with success/error states
- **Performance Metrics**: Execution time and status tracking
- **Error Handling**: Comprehensive error reporting and logging
- **Dynamic Options**: Load options from backend (e.g., Android device list, service actions)
- **Continuous Scheduling**: Temporal/Conductor pattern using `asyncio.wait(FIRST_COMPLETED)` - dependent nodes start immediately when their specific dependency completes
- **Event-Driven Deployment**: n8n-style architecture where triggers spawn independent concurrent execution runs (no iteration loop)

### Event-Driven Deployment Architecture (n8n Pattern)
The deployment system follows modern workflow engine patterns from n8n, Temporal, and Conductor:

```
deploy_workflow() -> Sets up triggers, returns immediately
                 |
                 +-> cronScheduler fires -> spawns ExecutionRun 1
                 +-> cronScheduler fires -> spawns ExecutionRun 2 (concurrent)
                 +-> whatsappReceive fires -> spawns ExecutionRun 3 (concurrent)
                 +-> webhookTrigger fires -> spawns ExecutionRun 4 (concurrent)
```

**Key Concepts:**
- **Workflow Template**: The deployed workflow is an immutable graph snapshot persisted on the `WorkflowControlExecution` row (`graph_snapshot` JSON column, `models/database.py`), written at Start by `services/deployment/control.py` and re-read to re-arm the controller after a crash
- **Execution Run**: Each trigger event spawns an independent, isolated run
- **Concurrent Runs**: Multiple runs execute simultaneously without interference
- **No Iteration Loop**: Purely event-driven, not polling or sequential iterations
- **Pre-Executed Triggers**: The firing trigger is marked complete before downstream execution. All other trigger nodes in the run are also marked `_pre_executed` with `{not_triggered: True}` to prevent them from blocking as event waiters

**Implementation Files:**
- `server/services/workflow.py`: Thin facade (~840 lines) delegating to specialized modules
- `server/services/node_executor.py`: Single node execution with registry-based dispatch
- `server/services/parameter_resolver.py`: Template variable resolution (`{{node.field}}`)
- `server/services/deployment/manager.py`: Deployment lifecycle, spawn runs, cancel
- `server/services/deployment/triggers.py`: Cron and event trigger management
- `server/services/deployment/state.py`: DeploymentState, TriggerInfo dataclasses
- `server/services/execution/models.py`: `ExecutionContext.create()` with `_pre_executed` support
- `server/services/execution/executor.py`: Continuous scheduling with `asyncio.wait(FIRST_COMPLETED)`

### AI Chat Model System (5-Layer Architecture)

Chat-model nodes (`openaiChatModel`, `anthropicChatModel`, ...) render through `SquareNode` from the backend NodeSpec. Direct chat and every new agent execution route through the native SDK facade (`ChatUnifier` in `services/llm/`); the provider surface is 11 cloud providers, Ollama and LM Studio, plus any number of user-named OpenAI-compatible endpoints (RFC-0003). Thirteen standalone chat-model nodes live under `server/nodes/model/` (one per fixed provider except xAI, which is selected directly from agent parameters, plus `openaiCompatibleChatModel` for a named endpoint). Model params (max output, context length, thinking type, temperature range) come from `ModelRegistryService`. The provider architecture, proxy/local-LLM routing, and the per-provider model + thinking/reasoning matrix (budget / effort / format) all live in **[Native LLM SDK](./docs-internal/native_llm_sdk.md)**. There is one wire standard (`MessageWire`) and no `llm_engine` / `message_wire_version` discriminator: the cutover-era markers and the `InvalidAgentLLMEngine` refusal path were purged (`tests/llm/test_single_wire_standard.py` fails the build if they reappear), and pre-cutover deployments are handled by Reset. The runtime output schema (`thinking` field for downstream nodes) is backend-served via `register_output_schema` (see [Plugin System](./docs-internal/plugin_system.md)).

## AI Agent Node Architecture

AI Agent (`aiAgent`) and Chat Agent / Zeenie (`chatAgent`) both use the plain-async `run_native_agent_loop` in `server/services/agent_runtime.py` and support memory / skills / tools / task input. `AIService.execute_agent()` and `execute_chat_agent()` prepare the same canonical native messages and `AgentToolSpec` values, then the loop appends each lossless assistant message before executing its tool calls, hot-rebinds the tool surface after canvas mutations, and stops when the model returns no tool calls (with `max_iterations` as a safety cap). Connection collection is `collect_agent_connections` in `server/services/plugin/edge_walker.py` (5-tuple: context, skill, tool, input, task); the pre-Wave-11 `handle_ai_agent`/`handle_chat_agent` handlers are gone (dispatch is per-plugin `execute_op` under `server/nodes/agent/<plugin>/__init__.py`). The legacy `_run_agent_loop` is deleted, as are the cutover-era `llm_engine` markers and the `InvalidAgentLLMEngine` refusal path; pre-cutover deployments are handled by Reset.

**`max_iterations` precedence differs by runtime. Temporal (`prepare_agent_payload`, highest->lowest): per-node `parameters.max_iterations` > `UserSettings.agent_recursion_limit` > env `AGENT_RECURSION_LIMIT` (default 200), with a hardcoded 200 if `Settings` cannot instantiate; it never reads `llm_defaults.json`. In-process (`execute_agent` / `execute_chat_agent`): `UserSettings.agent_recursion_limit` > `get_model_registry().get_agent_defaults()`, where env `AGENT_RECURSION_LIMIT` wins and `llm_defaults.json:agent.recursion_limit` is the last resort; the per-node tier is never read there, and no agent plugin currently declares a `max_iterations` Params field.**

Full reference — agent loop, skill injection, tool building, input/auto-prompt fallback (message>text>content>str), handle topology, spec-driven `AIAgentNode`, durable Task Manager delegation (Temporal child workflows plus the legacy bridge), and specialized-agent routing — in [docs-internal/agent_architecture.md](./docs-internal/agent_architecture.md); low-level compatibility mechanics in [agent_delegation.md](./docs-internal/agent_delegation.md), and the authoritative team-lead contract in [agent_teams.md](./docs-internal/agent_teams.md).

## Architecture Patterns
- **Plugin-first (Wave 11).** One folder per plugin under [`server/nodes/<group>/<plugin>/`](./server/nodes/) rooted at `__init__.py`, subclassing `BaseNode` / `ActionNode` / `TriggerNode` / `ToolNode`. Auto-registers via `__init_subclass__`. See [`docs-internal/plugin_system.md`](./docs-internal/plugin_system.md).
- **Backend NodeSpec is the SSOT** for icon, colour, handles, params, output schema, uiHints, palette group. Frontend consumes via `useNodeSpec(type)` and adapts the JSON Schema → `INodeTypeDescription` shape through [`adapters/nodeSpecToDescription.ts`](./client/src/adapters/nodeSpecToDescription.ts) (legacy interface kept as a render contract; not a parallel schema system).
- **Component-driven frontend.** shadcn/ui primitives + Tailwind tokens; canvas nodes are spec-driven (see "Spec-driven component design" above).
- **State management.** TanStack Query owns server-backed data; Zustand (`useAppStore`, `nodeStatusStore`) owns UI state and slice-subscribed high-frequency push state. Slice-selector reads only — never whole-store destructure (see "Frontend Performance Architecture").
- **Execution pipeline.** Temporal-distributed activities for plugin execution; `WorkflowExecutor` for parallel orchestration; per-node retry / timeout / heartbeat declared on the plugin class.

## File Structure Cleanup
**Removed Files:**
- `src/nodeDefinitions.ts` + `src/nodeDefinitions/` (27 files) — superseded by backend NodeSpec SSOT; frontend resolves specs via `lib/nodeSpec.ts` + `adapters/nodeSpecToDescription.ts` <!-- docs-lint: ignore; retired paths are recorded here as migration history. -->
- `src/nodeDefinitions.backup.ts` (backup file) <!-- docs-lint: ignore; retired paths are recorded here as migration history. -->
- `src/schemas/` directory (unused schema system)
- `src/utils/schemaParser.ts` (legacy parser) <!-- docs-lint: ignore; retired paths are recorded here as migration history. -->
- `src/utils/nodeSchemaParser.ts` (unused modern parser) <!-- docs-lint: ignore; retired paths are recorded here as migration history. -->
- `src/types/NodeSchema.ts` (legacy schema types) <!-- docs-lint: ignore; retired paths are recorded here as migration history. -->

**Cleaned Code:**
- Removed unused imports and dead functions
- Eliminated legacy NodeDefinition interface  
- Streamlined parameter handling logic
- Maintained backward compatibility only where actively used

## Testing & Validation
```bash
# Development server test
curl -I http://localhost:${PYTHON_BACKEND_PORT:-5678}

# TypeScript validation — the gate is TypeScript 7 (native Go) at the REPO ROOT.
bun run typecheck                               # root: tsc --noEmit -p client/tsconfig.json
bun run --filter react-flow-client typecheck    # what CI runs; delegates up to the same gate
# NOT a bare `bun x tsc --noEmit` (or `npx tsc`) — there is no root tsconfig.json.
# NOT client's `typecheck:tsc` — that resolves typescript@5.9.3, kept only because
# typescript-eslint's peer range excludes 6/7. It is a second opinion, not the gate.

# Generated-UI prompt files (server/config/*_prompt.txt), after changing a genui catalogue or
# client/src/lib/jsonRender/prompts.ts; a client test fails while one is stale
bun run genui:prompts

# Build verification
bun run build
```

## Production Deployment

### Self-Deploy CLI (`company deploy`) — current path
One command provisions a login-gated OpenCompany VM on GCP or AWS. Two stages: the
operator's **cloud CLI** (`cli/commands/deploy/providers/gcp.py` / `aws.py`) handles auth and context: gcloud does
project/region/zone resolution + ADC verification + API enablement; the aws CLI checks the signed-in identity, resolves
the region (`--region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, the CLI's configured region, then `us-east-1`), hands its
credentials to Terraform through `aws configure export-credentials` (so `aws login` sessions work) and refuses a region
without a default VPC. Then **Terraform** (`cli/terraform/<provider>/`, one shared variable set, so `up.py` writes one
tfvars shape) owns all resources. GCP: VM, firewall, artifact bucket (local `bun pm pack --quiet` source), service
account, and a GCE startup script (re-run on every boot) that installs bun (`BUN_INSTALL=/usr/local`, official installer) + uv, `bun add -g`s the package,
provisions the Python side with `company provision` (a global add runs no lifecycle scripts; the shim knows its own package root, since where bun keeps global packages varies by platform) and runs
`company serve` under systemd — no Node, no npm on the VM. AWS: a security group and an EC2 instance whose cloud-init
script (first boot only) runs `install.sh` as the login user with a published release (`--source release` only) and
points systemd at `$APP_HOME/.bun/bin/company` with a PATH `Environment=` line; the module ignores `ami` and `user_data`
changes, so a later apply never replaces a running instance and deletes its disk. Each adapter names its default
`--machine-type` (`e2-standard-2` / `t3.micro`) and the `--source` values its module supports. Login gate = built-in auth (`VITE_AUTH_ENABLED=true`,
`AUTH_MODE=single`) with the owner credential generated at deploy time and seeded on first boot.
`build_app_env` (`cli/commands/deploy/_secrets.py`) also sets `DEPLOYMENT_MODE=cloud` on
deployed VMs and mints fresh `JWT_SECRET_KEY` / `SECRET_KEY` / `API_KEY_ENCRYPTION_KEY` per deploy; Temporal, the event
framework and `HOST` keep their `.env.template` values (Temporal on, as in the Docker image). A VM that installs 0.2.0 or
0.2.1 stops with "Project not built" (errors.md #25, fixed after 0.2.1), which is every AWS VM until the next release.

`--name` (default `opencompany`; 6-30 lowercase letters, digits and hyphens, because it is also a cloud resource id)
names a deployment: its state folder and its resource id. One machine drives one VM per name, and a name stays with
the provider that created it.

```bash
company deploy up --provider gcp --owner-email you@example.com   # provision + install + print URL/creds
company deploy up --provider aws --name acme-corp --owner-email owner@acme.example
company deploy status [--name acme-corp]                         # URL + /health
company deploy destroy [--name acme-corp]                        # terraform destroy + clear state
```

Key files: `cli/commands/serve.py` (single-port runtime: one supervised uvicorn process fronting API + WS + built SPA;
the bun-hosted JS code-exec sidecar, WhatsApp and Temporal are backend-spawned on demand), `cli/commands/deploy/` (verbs, secrets, Terraform driver, provider CLI adapters),
`cli/terraform/gcp/` and `cli/terraform/aws/` (HCL modules + `startup.sh.tftpl`). `prepare_workdir` copies only a
module's `*.tf` / `*.tftpl` files, so Terraform state or tfvars left in a module folder never reach a deployment.
Deployment state lives at `<DATA_DIR>/deploy/<name>/` (`~/.opencompany/deploy/opencompany/` by default); only
`company deploy destroy` removes it (`company clean` keeps a repo-local `.opencompany/deploy`, see `_OPENCOMPANY_KEEP`).
The deploy code is fully delinked from `company build` /
`company clean` (lazy verb stubs in `cli/cli.py`; nothing in the build pipeline imports it).
For upgrades, `_state.py` also discovers pre-rebrand `deploy/machinaos/` state for the default name
under the configured root, `~/.machina`, or `<repo>/.machina`. Such deployments
retain their durable `machinaos` cloud/systemd id; changing it would cause
Terraform replacement or strand live state. Fresh deployments use `opencompany`.
The `machina` executable remains only as a deprecated legacy alias; use `company`
for all new commands and automation.

The legacy `deploy.sh` (docker-compose images over SCP to a GCE box) was removed.

### Docker (self-hosting)

`docker compose up -d --build` builds a single image from source and runs it with all state on one named volume. The image runs the backend the way the desktop shell does (uvicorn directly, env file on the data volume, readiness on `/health/ready`), not through `company serve`. Full guide: **[docker.md](./docs-internal/docker.md)**.

### Docker Deployment (legacy reference)

The historical Docker Compose topology (4-container stack: redis / backend / frontend / whatsapp, nginx reverse proxy, dev + prod compose files, env config, resource usage, useful commands) and the `npm run build` / `npm run preview` local-build commands are preserved in **[Deployment (legacy reference)](./docs-internal/deployment_legacy.md)**.

## Authentication System

n8n-style JWT auth in HttpOnly cookies. `VITE_AUTH_ENABLED=false` bypasses login (anonymous owner, dev); when enabled, `AUTH_MODE=single` (first user = owner, registration then closed) or `multi` (open registration). Backend: `User` (bcrypt) + `UserAuthService` + `/api/auth/*` router + `AuthMiddleware` (public-path allowlist + `/webhook/` prefix). `/login` and `/register` are throttled by the in-process `core/rate_limit.py` sliding window (`AUTH_RATE_LIMIT_*`; no `slowapi` dependency — the primary runtime is a single uvicorn worker). Frontend: `AuthContext` (a TanStack-Query bootstrap check that never retries on its own, plus `useMutation` for login/register/logout) + `ProtectedRoute` (the sign-in gate: Connecting while the server can't be reached, then sign-in or the app, and Connecting over the app while the WebSocket is down) + `LoginPage` (RHF + zod via the shadcn `Form` primitives; after a sign-in its welcome, `SignedIn`, stays while `signingIn` — the login/register mutations' own state, in flight or succeeded — until `finishSignIn` resets them); all API calls send `credentials: 'include'`, and the WebSocket refuses to connect without the cookie.

**Two context distinctions that are load-bearing, not stylistic:** `isLoading` is the bootstrap query (gates the whole app in `ProtectedRoute`) while `isSubmitting` is per-request — using the former to disable the login form disables nothing, since it has already settled by the time that form renders. And `error` means "cannot reach the server" while `submitError` is the server's own rejection text, shown as the server words it (or `AUTH_UNREACHABLE` when a sign-in got no answer); a `setQueryData` write is a *success* value, so routing a login failure through the status cache surfaces no error at all and additionally collapses `can_register`.

**Read the Known Limitations section of [authentication.md](./docs-internal/authentication.md) before building on this.** `AUTH_MODE=multi` provides authentication but no data isolation — `request.state.user_id` is written by the HTTP middleware (`middleware/auth.py`) and read by no route handler, so REST traffic shares one workflow store and one credential store. The WebSocket surface is different: `websocket.state.user_id` (`routers/websocket.py`) IS consumed by `execution_principal` (`services/authz/ws_surface.py`) to pick the principal an execution runs as, so identity is resolved there even though enforcement is missing. There is also no CSRF token and no token revocation (`User.is_active` is the only lever).

**Load-bearing rule:** JWT handling uses **PyJWT** (`import jwt`, HS256 with `Settings.jwt_secret_key`). Do NOT reintroduce `python-jose` — it drags in pure-Python `ecdsa` with the unpatchable Minerva timing-attack advisory (GHSA-wj6h-64fc-37mp). Full reference (models, router, middleware, config, startup retry, key files, deps) in **[Authentication](./docs-internal/authentication.md)**.

## Encrypted Credentials System

API keys and OAuth tokens live in a separate encrypted database (`credentials.db`): Fernet (AES-128-CBC + HMAC-SHA256) with the key derived via PBKDF2HMAC-SHA256 (600K iterations, OWASP-2024) from the server-scoped `API_KEY_ENCRYPTION_KEY` (`.env`) + a salt stored in `credentials.db`, initialized at startup and held for the process lifetime. Two distinct systems that never cross: API keys (`store_api_key`/`get_api_key` → `EncryptedAPIKey`) and OAuth tokens (`store_oauth_tokens`/`get_oauth_tokens` → `EncryptedOAuthToken`). `core/credential_backends.py` also defines Keyring and AWS Secrets Manager backends behind a `create_backend` factory, but nothing calls it: every install uses Fernet in `credentials.db`, and `CREDENTIAL_BACKEND` has no effect beyond `Settings` rejecting a value other than `fernet` / `keyring` / `aws`.

**Load-bearing rule:** every credential operation MUST go through `AuthService` (`from core.container import container; container.auth_service()`); routers must NEVER touch `CredentialsDatabase` directly. Full pipeline, cache contract, backends, config, key files, and design decisions are in **[Credentials Encryption](./docs-internal/credentials_encryption.md)**.

## Example Workflows

### Overview
Example workflows are pre-built workflow templates that auto-load on first use. They provide users with starting points to explore the platform's capabilities. Seeds live as JSON files at **`<repo>/.opencompany/workflows/`** — the only git-tracked content under `<repo>/.opencompany/` (everything else there is `.gitignore`d as runtime state). The path is also preserved by `company clean` (see `_OPENCOMPANY_KEEP` in `cli/commands/clean.py`).

### Architecture
```
<repo>/.opencompany/workflows/        # Shipped seed JSONs (git-tracked)
├── AI_Assistant.json
├── AI_Employee.json
└── Claude_Assistant.json

server/services/
└── example_loader.py             # Loads and imports examples via core.paths.example_workflows_dir()

server/models/database.py         # UserSettings.examples_loaded flag
server/core/database.py           # Migration for examples_loaded column
server/routers/database.py        # Auto-load logic in get_all_workflows
```

### How It Works
1. **First Fetch Detection**: When `get_all_workflows` API is called, it checks `UserSettings.examples_loaded`
2. **Auto-Import**: If `examples_loaded=false`, calls `example_loader.get_example_workflows()` which reads from `core.paths.example_workflows_dir()` = `<repo>/.opencompany/workflows/`
3. **Mark Complete**: Sets `examples_loaded=true` to prevent re-import on subsequent fetches
4. **Anonymous Support**: Uses `user_id="default"` when `VITE_AUTH_ENABLED=false`

### Workflow JSON Format
Example workflows use the same format as UI exports:
```json
{
  "id": "hello_world",
  "name": "Hello World",
  "description": "A simple workflow with a start node",
  "nodes": [
    {
      "id": "start_1",
      "type": "start",
      "position": {"x": 250, "y": 150},
      "data": {"label": "Start"}
    }
  ],
  "edges": [],
  "nodeParameters": {
    "start_1": { "someParam": "value" }
  },
  "version": "0.0.36"
}
```

**Fields:**
| Field | Description |
|-------|-------------|
| `id` | Unique identifier (prefixed with `example_` when imported) |
| `name` | Display name in workflow sidebar |
| `description` | Optional description |
| `nodes` | Array of node objects with id, type, position, data |
| `edges` | Array of edge connections between nodes |
| `nodeParameters` | Optional map of node_id to parameter objects (saved to DB on import) |
| `version` | App version (e.g., "0.0.36") |

### Key Files
| File | Description |
|------|-------------|
| `.opencompany/workflows/*.json` | Shipped seed workflow JSONs (git-tracked) |
| `server/core/paths.py` | `example_workflows_dir()` → `<repo>/.opencompany/workflows/` (fixed path, NOT under `DATA_DIR`) |
| `server/services/example_loader.py` | `get_example_workflows()`, `import_examples_for_user()` |
| `server/models/database.py` | `UserSettings.examples_loaded` field |
| `server/core/database.py` | Migration adds `examples_loaded` column |
| `server/routers/database.py` | Auto-load check in `get_all_workflows` |

### Example Loader Service
```python
# server/services/example_loader.py
from core.paths import example_workflows_dir

def get_example_workflows() -> List[Dict[str, Any]]:
    """Load all example workflow JSON files from disk."""
    examples_dir = example_workflows_dir()
    ...

async def import_examples_for_user(database) -> int:
    """Import all examples using existing database.save_workflow().
    Returns count of workflows imported."""
```

### Auto-Load Logic
```python
# server/routers/database.py - get_all_workflows endpoint
user_id = "default"
settings = await database.get_user_settings(user_id)

if not settings or not settings.get("examples_loaded", False):
    count = await import_examples_for_user(database)
    if count > 0:
        logger.info(f"Auto-loaded {count} example workflows")
    current = settings or {}
    current["examples_loaded"] = True
    await database.save_user_settings(current, user_id)
```

### Adding Custom Examples
1. Export a workflow from the UI (File > Export)
2. Copy the JSON file to `<repo>/.opencompany/workflows/` (git-tracked seed location)
3. Edit the `id` and `name` fields as needed
4. Stop the app and delete `~/.opencompany/workflow.db` with its `workflow.db-wal` and `workflow.db-shm` files (or set `examples_loaded=false` in DB)
5. Restart server - examples auto-load on first workflow list fetch

### Database Migration
The `examples_loaded` column is automatically added to existing databases:
```python
# server/core/database.py - _migrate_user_settings()
if "examples_loaded" not in columns:
    await conn.execute(text(
        "ALTER TABLE user_settings ADD COLUMN examples_loaded BOOLEAN DEFAULT 0"
    ))
```

## Onboarding Service

The Welcome guide on Home ([features/home/onboarding/](./client/src/features/home/onboarding/), mounted by `HomeShell`): Welcome, Connect an AI model (the shared `CredentialsBrowser` cut to AI models) and Your first hire (Home's composer through genui's effect-free `useJobComposer`). It opens on first launch for an owner whose `UserSettings.onboarding_completed` is false, at the saved `onboarding_step`, and again from the header's Guide button or Settings → Help; skipping finishes it, and existing users were marked finished by the migration (`onboarding_completed=1` where `examples_loaded=1`). The open state and step live in `homeStore.guide` with a once-per-session `checked` flag, so a remount never reopens it. Once it is finished, a Get started checklist ticks off four first steps (two latched in `UserSettings`: `getting_started_said_hello`, `getting_started_approved_draft`): a card in the hire view's corner (at the Workspace dock's left edge while the dock is open), and on an employee's page, where the card would cover Send, a "Get started · n/4" pill in the header that goes back to the hire view with the card open (`GetStartedPill`, `homeStore.openGetStarted`; the fold is `homeStore.getStartedFolded` for the session). Card and pill share one rule in `useGetStarted`: settings loaded, `onboarding_completed`, not dismissed, and neither the guide nor the AI models dialog open. Whatever on Home needs an AI model opens Home's AI models dialog (`homeStore.openConnectAI`, `onboarding/AiModelsDialog.tsx`), not the shell's credentials dialog: the guide's AI model list, with a plain page for a model that is one field and a button (`AiModelPage`). With `VITE_NORMAL_MODE=false` there is no Home and so no guide, no checklist and no AI models dialog. Steps, state, tests and key files are in **[Onboarding Service](./docs-internal/onboarding.md)**.

## AI Chat Model Development Guide

A new chat-model provider is one self-contained folder under `server/nodes/model/<provider>_chat_model/` with `__init__.py` declaring a `ChatModelBase` subclass (auto-registers via `BaseNode.__init_subclass__`; the frontend renders it through `SquareNode` from the NodeSpec with zero TS changes). Credentials live in `server/nodes/model/_credentials.py`. The full recipe — native provider registration under `services/llm/providers/`, `llm_defaults.json` configuration, the `_COMPAT_PROVIDERS` entry required for OpenAI-compatible endpoints, and the per-provider implementation-file map — is in **[Native LLM SDK](./docs-internal/native_llm_sdk.md)**.

## Context and Memory (RFC-0002)

Two different things, deliberately separated. Read [RFC-0002](./RFC-0002-AGENT-CONTEXT-AND-MEMORY.md) before touching either.

**Context** is the plain conversation store — what the agent actually sent and received, as JSON messages. One table (`agent_conversations`, [models/agent_context.py](./server/models/agent_context.py)) keyed by **`(workflow_id, generation, agent_node_id)`** → `messages` (MessageWire list) + `updated_at`; the service is four functions in [`services/agent_context/conversation.py`](./server/services/agent_context/conversation.py) (`load_conversation` / `save_conversation` (per-key lock, whole-list upsert, post-commit notify) / `clear_conversation` / `list_conversations`). Every firing of an agent — chat message or taskTrigger review — loads the row at run start and saves the live message list back per turn, so the one conversation continues across firings; Reset admits a new generation = new key = fresh conversation, AND the Context node's `reset_execution_state` clears the workflow's stored rows + fences warm claude subprocesses + broadcasts `context.updated` — the generation bump alone is not enough because the panel shows the newest STORED generation, so surviving rows would render the pre-Reset conversation as the live context and Reset would look like a no-op. (A plain Stop→Start leaves prior generations as inert, non-browsable history until workflow delete or Reset.) The `context` node (`server/nodes/context/`, `input-context` handle) is the **opt-in switch and viewing panel**, nothing more — it declares no parameters. It is an ordinary canvas node the user deletes like any other: `normalize_workflow_graph` never creates, reconnects or deletes one (rewriting a legacy `simpleMemory -> input-memory` edge is the one exception), save never restores a deleted one, and `workflow_validator` does not require one — `INVALID_CONTEXT_EDGE` / `MULTIPLE_CONTEXTS` / `SHARED_CONTEXT` still reject ambiguous wiring. Deleting it opts the agent out, and the save's archive outbox then clears the workflow's stored conversations. The palette does not offer it (`uiHints.systemManaged`), so a deleted Context cannot be re-added from there. This is the LangGraph `thread_id` / OpenAI `conversation_id` pattern; the previous hash-chained journal (threads, epochs, checkpoints, blobs, provider bindings, session/task/execution thread routing) is deleted.

Load-bearing invariants, learned the hard way: (1) *attaching a Context node must never change what the agent sends* — requests are always built from `messages`; `conversation_key` only says where to save. (2) *The store records what was sent, after it was sent* — `agent.execute_llm_step` saves `[...sent, assistant]` immediately after `ChatUnifier.chat` returns; nothing writes before a request exists (the retired `prepare_context` activity did, and journalled fabricated requests). (3) **Loads are LOUD, saves are best-effort** — `agent.prepare_payload` raises `ApplicationError("ConversationLoadFailed")` (retryable) / `ApplicationError("ConversationTooLarge")` (non-retryable, 1 MB cap) rather than silently running an amnesiac agent that burns tokens on an empty prompt; the save runs post-billing and swallows failures with a WARN. (4) Seeding precedence is **carried rollover transcript > stored conversation > bare build** — a `continue_as_new` resume carries the live transcript in its resume marker (size-guarded ≤ 1 MB) and must win over the store. Locked by `tests/temporal/test_agent_workflow.py::TestConversationIdentity`.

Both paths implement the same contract: Temporal via `prepare_agent_payload` (load) + `_save_conversation` in the LLM activity; in-process via `AIService._prepare_context` → `_AgentContextRuntime{key, history, database}` + the loop's `conversation_saver=runtime.save` hook. Specialized providers (claude_code / codex / rlm) use `SpecializedAgentContextBridge` ([services/cli_agent/context_bridge.py](./server/services/cli_agent/context_bridge.py)): `resolve` loads, `augment_prompt` renders the transcript into the prompt, `record_turn(original_prompt, response)` saves — always the ORIGINAL prompt, never the augmented one, or the save nests the transcript inside itself. The claude pool key is the conversation key (`(workflow_id, agent_node_id, generation)`), so a Reset's generation bump fences warm subprocesses at `acquire`; a same-generation panel Clear calls `ClaudeSessionPool.terminate_conversations`. Manual canvas Runs persist nothing (`generation > 0` gate — only Start admits a generation).

**Live viewing is emitted at the persistence boundary, not per caller.** `save_conversation` is the one place every writer passes through, and it announces durable saves through the fanout registry in [`services/agent_context/listeners.py`](./server/services/agent_context/listeners.py) — `register_conversation_listener` / `notify_conversation_saved` — with `nodes/context/__init__.py` registering the broadcaster. Same shape as the plugin registries, same reason: the store must never import `nodes/`. The notification fires after the commit, and a listener can never fail a save (failures log at WARNING — a silently failing listener is exactly how "the panel never updates live" becomes undiagnosable). The single Context event (`context.updated`) **broadcasts directly via `get_status_broadcaster()`, not through `services.events.dispatch.emit`** — no canary consumer exists for `com.opencompany.context.*`, so `emit`'s Visibility query would match nothing once per save. Payload is identity + count only; the panel refetches through the authorized `get_agent_context` WS handler (`clear_agent_context` is the other handler; the journal-era fork/export handlers are gone). One `case 'context.updated'` in `WebSocketContext.tsx` invalidates `['agentContext']`; the panel contains no subscription code. See [event_framework.md → UI-only lifecycle events](./docs-internal/event_framework.md).

**The store also stamps timestamps.** `save_conversation` attaches a `ts` (UTC ISO) to every stored message: callers regenerate wires from live `Message` objects each turn, so the store keeps the original stamp for the unchanged prefix and stamps only the appended turn — a message's time is when it first persisted, not when the conversation last saved. `ts` is a stored-view field only; `message_from_wire` ignores it, so it never reaches a provider on any path.

**Compaction never touches the system prompt, and its summary rides a USER message.** The AgentWorkflow's threshold compaction swaps `messages` for `[original system (verbatim), user(summary + current request)]`, followed by the latest turn verbatim on runs recorded with `context_pressure_version` 1 (its tool results are still unread). The system prompt is the agent's byte-stable contract (prompt caching, behavioral consistency), and the seeding filter drops stored system messages — so a summary stored under the system role (the earlier design) silently vanished on the next firing while the noisy tool tail survived it. Tool calls/results of the summarized turns are dropped at the swap and live only inside the summary; tool messages after the summary in the panel are the kept latest turn or new post-compaction work. Trigger, swap (before→after counts), and summarizer usage all log at INFO; a summarizer failure after the activity's retries fails the run (`CompactionError`), it is not best-effort. Locked in `tests/temporal/test_agent_workflow.py` and `tests/temporal/test_agent_workflow_pressure.py`. See [agent_context_flow.md → Compaction](./docs-internal/agent_context_flow.md).

**The transcript is bounded in bytes, not only in tokens** (`docs-internal/errors.md` #28: uncapped TikHub results grew a saved row past the 1 MB seed cap, and every later firing failed with `ConversationTooLarge`). Each external tool result is cut to `tool_result_max_chars` characters (per-user Settings > Tool Result Limit, else env `TOOL_RESULT_MAX_CHARS`, default 100,000; delegated-agent answers, skill loads and Task Manager are exempt) by `server/services/tool_output.py`, on both paths. On Temporal, `agent.prepare_payload` records `tool_result_max_chars`, `transcript_budget_bytes` (three quarters of `TEMPORAL_PAYLOAD_WARN_BYTES`) and `context_pressure_version`; after each tool turn over the budget the workflow clears earlier turns' results to a placeholder (oldest and external first), gates summarization on the NEXT request (the last step's `total_tokens` plus the turn's added characters, never a running sum of every step), summarizes only earlier turns, and cuts an overflowing latest turn to fit (`server/services/temporal/agent_context_pressure.py`). The recorded version selects these rules on replay: a history recorded without it replays the original cumulative gate, so any change to the rules needs a new version. See [agent_context_flow.md → Transcript size](./docs-internal/agent_context_flow.md).

**Memory** (node type `simpleMemory`, displayed as **"Memory"** — the wire type is stored verbatim in graphs/pool keys and must not be renamed; plugin `server/nodes/tool/simple_memory/`) is a `ToolNode` on `input-tools` — durable facts the agent explicitly remembers, recalls, updates and forgets. It is not conversation history and has no markdown surface; `SimpleMemoryParams` declares only `reset_policy` (with `extra="ignore"`, so leftover legacy keys on migrated graphs are inert). `reset_policy` is in `server_controlled_fields`, which stops the **model** overriding it through tool arguments — it does not stop the operator editing it. Two model-facing contracts are load-bearing: (1) the `tool_description` ClassVar is imperative — *check memory (recall/list) before answering anything about the user; never claim ignorance without checking* — because with a passive "store and retrieve when useful" wording, models answered "I don't know who you are" from priors while the user's name sat one recall away (`tool_schema_locked = True` pins this description against stale ToolSchema rows). (2) Every durable mutation (remember/update/forget/clear, from the agent tool call or the panel) broadcasts the identity-only `memory.updated` CloudEvent ([`_events.py`](./server/nodes/tool/simple_memory/_events.py), same direct-broadcast rationale as `context.updated`), which `WebSocketContext.tsx` routes to invalidate the `memoryItems`/`memoryItem` queries.

Lifecycle helpers live in `server/services/memory/`; token tracking and compaction thresholds are in **[Memory Compaction](./docs-internal/memory_compaction.md)**. The pre-RFC-0002 markdown model is archived at [ARCHIVE/memory_lifecycle.md](./docs-internal/ARCHIVE/memory_lifecycle.md).

## Memory Compaction, Token Tracking, and Cost Calculation

Memory-connected standard agents aggregate native `Usage` across every model
turn. The in-process path calls `CompactionService.track()` after execution to
persist session metrics and can trigger `compact_context()`, which performs a
shared client-side summarization call through `ChatUnifier`. The Temporal
`AgentWorkflow` also aggregates and returns loop usage and can invoke the same
summarizer, but it does not currently persist that loop aggregate through
`CompactionService.track()`. Provider-native Anthropic/OpenAI context-management
compaction is not used by the agent runtime. Executions without connected
memory still aggregate usage inside the loop but do not create session
compaction records.

**Threshold precedence inside `CompactionService.track()` (highest-to-lowest): per-session `SessionTokenState.custom_threshold` > per-user `UserSettings.compaction_ratio` > env `COMPACTION_RATIO` (default 0.8) > `llm_defaults.json:agent.compaction.ratio`.** The Temporal agent workflow currently prepares its threshold from the model/user/env ratio without reading the per-session custom threshold, so do not promise that override on every execution path.

Full reference — the `CompactionService` API, shared client-side compaction, the 5-section summary format, DB models (`TokenUsageMetric` / `SessionTokenState` / `CompactionEvent`), WS handlers, and the broadcast events — in **[Memory Compaction](./docs-internal/memory_compaction.md)**; per-service API cost tracking is in **[Pricing Service](./docs-internal/pricing_service.md)**.

## API Cost Tracking

Centralized cost tracking for third-party API services (Twitter/X, Google Maps). See [Pricing Service](./docs-internal/pricing_service.md) for full documentation.

### Two Tracking Methods

**1. Manual Tracking** - For services using native SDKs:
```python
# usage tracked inside server/nodes/twitter/
await _track_twitter_usage(node_id, 'tweet', 1, workflow_id, session_id)

# usage tracked inside server/nodes/location/_service.py
await _track_maps_usage(node_id, 'geocode', 1, workflow_id, session_id)
```

**2. Automatic HTTPX Tracking** - For services using httpx client:
```python
from services.tracked_http import get_tracked_client, set_tracking_context

set_tracking_context(node_id="twitter-1", session_id="user-123")
client = get_tracked_client()
response = await client.post("https://api.twitter.com/2/tweets", json={...})
# Automatically tracked via HTTPX response event hook!
```

### Pricing Configuration

All pricing in `server/config/pricing.json` (user-editable):
- `llm`: Per-model token pricing (USD/MTok)
- `api`: Per-service operation pricing (USD/request)
- `operation_map`: Maps handler actions to pricing operations
- `url_patterns`: Regex patterns for automatic HTTPX tracking

### Database Storage

`APIUsageMetric` table stores: service, operation, endpoint, resource_count, cost (USD)

### Frontend Display

`credentials/sections/ApiUsageSection.tsx` shows per-service API usage and costs; `LlmUsageSection.tsx` shows per-provider token usage. Both read **precomputed** costs (`SUM(APIUsageMetric.cost)` / `TokenUsageMetric`) — neither reads `config/pricing.json`, which is consumed only server-side by `PricingService` at execution time.

## AI Agent Tool System

### Overview
Tool nodes provide capabilities that AI Agents can invoke during reasoning. Each tool node connects to the AI Agent's `input-tools` handle and defines a schema for the LLM to understand how to call it.

### Architecture
```
Tool Node (calculatorTool) → (tool output) → AI Agent (input-tools handle)
                                                    ↓
                                            AIService builds AgentToolSpec values
                                                    ↓
                                            run_native_agent_loop sends ToolDef schemas
                                                    ↓
                                            LLM selects zero or more tool calls
                                                    ↓
                                            Tool executor runs handlers
                                                    ↓
                                            Native tool results return to LLM
```

### Tool Execution Flow
1. **Tool Discovery**: AI Agent scans edges for nodes connected to `input-tools` handle
2. **Schema Building**: `_build_tool_from_node()` in `ai.py` combines the Pydantic validation model with an inlined JSON schema in an `AgentToolSpec` / `ToolDef`
3. **Provider Compilation**: `run_native_agent_loop` passes provider-neutral `ToolDef` values through `ChatUnifier`; each native provider compiles the schema it supports
4. **LLM Decision**: LLM decides when to call tools based on user query
5. **Status Broadcast**: `executing_tool` status broadcast with tool_name for UI animation
6. **Tool Execution**: `execute_tool()` in `tools.py` dispatches to appropriate handler
7. **Result Return**: Native tool-result messages are appended for continued reasoning

### Key Files
| File | Description |
|------|-------------|
| `server/services/handlers/tools.py` | Tool execution handlers |
| `server/services/ai.py` | `_build_tool_from_node()` / `_get_tool_schema()` - provider-neutral tool specs and Pydantic validation |
| `server/services/agent_runtime.py` | `AgentToolSpec` and the shared native agent loop |
| `server/services/plugin/edge_walker.py` | `collect_agent_connections` — tool/skill/memory discovery from edges |

### Adding a new tool or specialized agent (Wave 11)

**Single source of truth: [`server/nodes/README.md`](./server/nodes/README.md)** (5-minute recipe) and [`docs-internal/plugin_system.md`](./docs-internal/plugin_system.md) (full reference). The pre-Wave-11 `toolNodes.ts` / `specializedAgentNodes.ts` / `AGENT_CONFIGS` files do not exist — the canonical authoring shape is one Python file.

The whole workflow:

```python
# server/nodes/tool/<plugin>/__init__.py     ← for a tool
# server/nodes/agent/<plugin>/__init__.py    ← for a specialized agent
class MyTool(ToolNode):              # or SpecializedAgentBase for an agent
    type = "myTool"
    display_name = "My Tool"
    group = ("tool", "ai")
    component_kind = "tool"          # or "agent"
    Params = MyParams                # Pydantic — feeds UI + AI tool schema
    Output = MyOutput
    @Operation("run")
    async def run(self, ctx, params): ...
```

`BaseNode.__init_subclass__` registers the class into `_NODE_CLASS_REGISTRY` (first), then into `NODE_METADATA`, `_DIRECT_MODELS`, `NODE_OUTPUT_SCHEMAS`, and `_HANDLER_REGISTRY` on import. The class-registry-first order matters: `_metadata_dict` calls `get_plugin_icon_path(cls.type)` which goes through `get_node_class()`. NodeSpec emits at `GET /api/schemas/nodes/<type>/spec.json`. The frontend auto-discovers via `useNodeSpec` + `componentKind` dispatch (see "Spec-driven component design" above). Icon goes in `<plugin>/icon.svg` (or `visuals.json` for emoji / library brand); color goes in `<plugin>/meta.json` (or `visuals.json` legacy fallback).

**Cross-cutting edits that are still required (small):**
- New specialized agent: update `AI_AGENT_TYPES` and the canonical teammate
  discovery/validation surfaces so a team lead can authorize it through
  `input-teammates`. Team leads delegate with Task Manager; `delegate_to_*`
  remains an internal/legacy dispatch identity and must not be taught as the
  model-facing team contract.
- Brand-new uiHint flag: add to `INodeUIHints` in [`client/src/types/INodeProperties.ts`](./client/src/types/INodeProperties.ts) AND to the `known` set in `test_ui_hints_only_carry_known_flags` (`server/tests/test_node_spec.py`).

**No edits needed:** any TypeScript node definition file, `_get_tool_schema()`, `AGENT_CONFIGS`, `AGENT_WITH_SKILLS_TYPES`, `aiAgentTypes`, `Dashboard.tsx`, `MiddleSection.tsx`, `InputSection.tsx`, or any of the other arrays the pre-Wave-11 docs listed.

### Tool Execution Animation
Tool nodes display execution status via the standard node status system:
- Backend broadcasts `executing` status to tool node when AI Agent calls it
- `SquareNode.tsx` uses `getNodeStatus()` from WebSocket context
- Tool nodes show cyan border and pulse animation when `isExecuting` is true
- **Minimum glow duration**: 500ms ensures fast-executing tools are visible (via `isGlowing` state)
- Dual-purpose tools (Python/JavaScript) fall back to node params when LLM returns empty args

### Implemented Tools
| Tool | Schema | Handler | Description |
|------|--------|---------|-------------|
| calculatorTool | `CalculatorParams` | `CalculatorToolNode.calculate` (`nodes/tool/calculator_tool/`) | Math operations |
| currentTimeTool | `CurrentTimeParams` | `CurrentTimeToolNode.now` (`nodes/tool/current_time_tool/`) | Date/time with timezone |
| duckduckgoSearch | `DuckDuckGoSearchParams` | `DuckDuckGoSearchNode.search` (`nodes/search/duckduckgo_search/`) | DuckDuckGo web search (free) |
| taskManager | `TaskManagerParams` | `_execute_task_manager()` | Intrinsic durable team assignment; returns queued through detached Temporal runner, preserves cross-run history, review, retry/reassignment, cancellation, acceptance, timestamps, elapsed time, and token usage |
| writeTodos | `WriteTodosParams` | `execute_write_todos()` / `handle_write_todos()` | Structured task list planning with checklist rendering |
| braveSearch | `BraveSearchParams` | `BraveSearchNode.search` (`nodes/search/brave_search/`) | Brave Search API web results |
| serperSearch | `SerperSearchParams` | `SerperSearchNode.search` (`nodes/search/serper_search/`) | Google SERP via Serper API |
| perplexitySearch | `PerplexitySearchParams` | `PerplexitySearchNode.search` (`nodes/search/perplexity_search/`) | AI-powered search with citations |
| Android service nodes | Per-service schema | `execute_android_service_tool()` (`nodes/android/_base.py`) | Direct Android service tools (see below) |

### Direct Android Service Tools
Android service nodes (batteryMonitor, wifiAutomation, etc.) connect directly to any agent's `input-tools` handle — this is the only Android tool path (the former `androidTool` aggregator was retired; legacy graphs are migrated on load by `workflow_migrations.normalize_legacy_android_toolkit`). The `execute_tool()` function detects these via `ANDROID_SERVICE_NODE_TYPES` and routes to `execute_android_service_tool()` (`server/nodes/android/_base.py`).

**Service ID Mapping** (camelCase node type -> snake_case service ID):
```python
service_id_map = {
    'batteryMonitor': 'battery',
    'networkMonitor': 'network',
    'systemInfo': 'system_info',
    'location': 'location',
    'appLauncher': 'app_launcher',
    'appList': 'app_list',
    'wifiAutomation': 'wifi_automation',
    'bluetoothAutomation': 'bluetooth_automation',
    'audioAutomation': 'audio_automation',
    'deviceStateAutomation': 'device_state',
    'screenControlAutomation': 'screen_control',
    'airplaneModeControl': 'airplane_mode',
    'motionDetection': 'motion_detection',
    'environmentalSensors': 'environmental_sensors',
    'cameraControl': 'camera_control',
    'mediaControl': 'media_control',
}
```

### Android Toolkit Pattern (retired)
The former `androidTool` gateway node (single `android_device` tool aggregating multiple Android service nodes) no longer exists. Android service nodes connect straight to `input-tools`, and sub-node exclusion keys solely on AI-agent config handles (`input-context` / `input-tools` / `input-skill` / `input-teammates`) — the `TOOLKIT_NODE_TYPES` constant was deleted along with all its usage sites. Legacy `service -> androidTool -> agent` graphs are rewritten on load by `services/workflow_migrations.normalize_legacy_android_toolkit`.

### Tool Schemas (per-service customization)
Custom LLM-visible schemas for Android service tools persist in the `tool_schemas` table and are read by the AI service at execution time.

**The write path has no reachable entry point — this is a known gap, not a design.** `ToolSchemaEditor.tsx` shipped with the retired Android Toolkit node and was removed with it; the `useToolSchema` client hook was its only consumer and was deleted in turn once it had zero importers. The WebSocket handlers and database CRUD remain, but nothing reachable calls `save_tool_schema` any more: no REST route writes the table and no server-internal code calls `database.save_tool_schema` outside the WS handler itself. So rows can only survive from before the editor was removed, or be written by hand.

The **read** path is live and load-bearing — do not delete it as "unused". [`services/ai.py`](./server/services/ai.py) fetches the stored row during tool construction and overrides `tool_name` / `tool_description` / `connected_services` from it, and `_build_schema_from_config` turns `schema_config["fields"]` into the actual LLM-visible tool schema. Restoring an editor means giving `save_tool_schema` a caller again; it does not require touching the read side.

#### Architecture
```
(no client writer — see above)
        ↓
  Database (tool_schemas table)
        ↓
  AI Service reads schemas at execution
```

#### Key Files
| File | Description |
|------|-------------|
| `server/models/database.py` | `ToolSchema` SQLModel table definition |
| `server/core/database.py` | Database CRUD methods for tool schemas |
| `server/routers/websocket.py` | WebSocket handlers for schema operations |

#### Database Model
```python
class ToolSchema(SQLModel, table=True):
    __tablename__ = "tool_schemas"
    node_id: str          # Service node ID (unique key)
    tool_name: str        # Display name (e.g., "Battery Monitor")
    tool_description: str # Description shown to LLM
    schema_config: Dict   # Schema fields and types (JSON)
    connected_services: Optional[Dict]  # Legacy field from the retired toolkit aggregation
```

#### WebSocket Messages
| Message Type | Description |
|--------------|-------------|
| `get_tool_schema` | Get schema for a node by ID |
| `save_tool_schema` | Save/update schema for a node |
| `delete_tool_schema` | Delete schema for a node |
| `get_all_tool_schemas` | Get all stored schemas |

#### Default Schema Generation
When no custom schema exists, service-specific defaults are generated:
```typescript
{
  description: `Control ${serviceName} on Android device`,
  fields: {
    action: { type: 'string', description: `Action to perform on ${serviceName}`, required: true },
    parameters: { type: 'object', description: `Parameters for the ${serviceName} action`, required: false }
  }
}
```

### Web Search Implementation

#### DuckDuckGo (duckduckgoSearch - free, no API key)
Uses `ddgs` library for web results:
```python
from ddgs import DDGS
def do_search():
    ddgs = DDGS()
    return list(ddgs.text(query, max_results=max_results))
search_results = await asyncio.get_event_loop().run_in_executor(None, do_search)
```

#### Search API Nodes (braveSearch, serperSearch, perplexitySearch)
Dedicated plugins under `server/nodes/search/` (`brave_search` / `serper_search` / `perplexity_search`) using `httpx.AsyncClient`:
- **Brave Search**: `GET https://api.search.brave.com/res/v1/web/search` with `X-Subscription-Token` header. Returns `{query, results: [{title, snippet, url}], result_count, provider}`.
- **Serper**: `POST https://google.serper.dev/search` with `X-API-KEY` header. Supports web/news/images/places search types. Returns `{query, results, result_count, search_type, provider}` with optional `knowledge_graph`.
- **Perplexity Sonar**: `POST https://api.perplexity.ai/chat/completions` with Bearer token. Returns `{query, answer (markdown), citations: [url], results: [{url}], model, provider}` with optional `images` and `related_questions`.

All handlers fetch API keys via `auth_service.get_api_key()`; usage is tracked declaratively through the `cost={...}` argument on each node's `@Operation("search", ...)`.

## Config Node Architecture

### Overview
Config nodes (context, tools, models) connect to parent nodes via special "config handles" (e.g., `input-context`, `input-tools`). These are auxiliary connections for configuration, not main data flow. The UI intelligently handles visibility of connected inputs based on this architecture.

### Config Handle Convention
Config handles follow the pattern `input-<type>` where type is NOT 'main':
- `input-context` - Context node (RFC-0002; observation surface onto the journal)
- `input-tools` - Tool nodes (including `simpleMemory`, which is a ToolNode)
- `input-model` - Model configuration nodes
- `input-skill` - Skill nodes
- `input-task` - Task completion trigger nodes
- `input-teammates` - Team member agent nodes
- `input-main` - Main data flow (NOT a config handle)
- `input-memory` - **retired.** Kept only so legacy graph snapshots recorded with it replay; no agent declares it. `normalize_workflow_graph` rewrites legacy `simpleMemory -> input-memory` edges into a Context node plus an ordinary tool edge.

**Note**: Trigger nodes (e.g., `taskTrigger`) connecting via config handles are excluded from downstream inclusion in `_get_downstream_nodes()` to prevent them from blocking as event waiters.

### Config Node Detection
Nodes are identified as config nodes by their `group` array in the node definition:
```typescript
// Config node example (simpleMemory)
group: ['skill', 'memory']  // 'memory' or 'tool' indicates config node
```

### Input Inheritance
Config nodes automatically inherit their parent node's main inputs in the parameter panel:
```
WhatsApp Trigger → AI Agent ← Simple Memory
       ↓              ↑
   main input    config handle

When viewing Simple Memory's parameters:
- Shows: "WhatsApp Trigger (via AI Agent)"
- Can drag WhatsApp outputs into Memory's parameters
```

### Filtering Logic
Located in `InputSection.tsx`:
1. **Parent nodes** (AI Agent): Skip showing config node connections as inputs
2. **Config nodes** (Memory): Inherit parent's main input connections with "(via Parent)" label

### Key Functions
```typescript
// Check if handle is for config nodes (not main data flow)
const isConfigHandle = (handle: string | null | undefined): boolean => {
  if (!handle) return false;
  if (handle.startsWith('input-') && handle !== 'input-main' && handle !== 'input-chat' && handle !== 'input-task' && handle !== 'input-teammates') {
    return true;
  }
  return false;
};

// Check if node is a config/auxiliary node — reads the backend-derived
// uiHint, not a frontend group-string heuristic.
const isConfigNode = (nodeType: string | undefined): boolean => {
  if (!nodeType) return false;
  const definition = resolveNodeDescription(nodeType);
  return definition?.uiHints?.isConfigNode === true;
};
```

The `isConfigNode` flag is **auto-derived on the backend** by `_derive_auto_ui_hints` in [`server/services/plugin/base.py`](./server/services/plugin/base.py): plugins whose `group` tuple contains `memory` or `tool` (the centralized `_CONFIG_NODE_GROUPS = frozenset({"memory", "tool"})`) automatically export `uiHints.isConfigNode: True`. Explicit `cls.ui_hints` always wins (merge order: auto-derived first, then `dict.update` with the plugin's declaration). Pytest invariant `test_ui_hints_only_carry_known_flags` locks the flag name in `server/tests/test_node_spec.py`.

### Adding New Config Node Types
1. Put the plugin in `('memory',)` or `('tool',)` (or any tuple containing one of those). The backend auto-derivation does the rest — do NOT declare `isConfigNode` in `ui_hints` unless you want to override.
2. Use `input-<type>` naming for the target handle on the parent node.
3. Input inheritance and filtering work automatically — the frontend reads `definition.uiHints.isConfigNode`, never the group strings.

### Sub-Node Execution Exclusion

Sub-nodes (tools, memory, skills, teammates) connect TO an agent, not from it, so in parallel execution mode Kahn's algorithm would see them with in-degree 0 and incorrectly schedule them in layer 0. The executor excludes them: any node whose edge targets an AI-agent config handle (`input-context`, `input-tools`, `input-skill`, `input-teammates`) is detected as a sub-node in `ExecutionContext.create()` / `_compute_execution_layers()` and skipped from execution layers — it executes only when the agent invokes it (e.g. as an AI tool).

Toolkit aggregator nodes (the former `androidTool` and its `TOOLKIT_NODE_TYPES` constant) were retired; Android service nodes now connect directly to the agent's `input-tools` handle. Legacy `service -> androidTool -> agent` graphs are migrated on load by `services/workflow_migrations.normalize_legacy_android_toolkit` (pure, idempotent: services re-wire directly to each agent, orphaned toolkits are removed with a warning).

## Android Services Development Guide

### Local emulator workspace (separate plugin)

`server/nodes/mobile/` owns **Mobile Agent** (`mobile_use_agent`) and the **Android** tool (`android_tool`). Both declare `ui_hints.workspace.kind="mobile"`; Home and Dev render `MobileWorkspace`. Android’s Tool output connects to an agent’s Tools input. Both phone nodes follow the saved global model by default; custom settings override it, and a connected Model node takes highest priority. Its AI-visible schema contains only `prompt`; saved configuration controls time/step limits. This plugin does not replace the relay services documented below.

Startup must hold `driver_lock` while reading the initialization reply; regular commands use the same lock. `/status` must skip driver I/O during lifecycle transitions. A serial alone does not mean the phone is ready. Keep connection warnings separate from action errors and clear them after successful polling.

Diagnostics live in `mobile/mobile.log` (1 MiB, three backups); recent events are exposed under **Help & diagnostics**. Never log prompts, credentials, screenshots or typed text. Relative mobile `DATA_DIR` currently resolves against backend CWD, so source-dev installations can live at `server/.opencompany/mobile`; that runtime directory is ignored by Git and must not be deleted as temporary test data. Full setup, lifecycle, storage caveats and iOS plan: [Mobile Workspace](docs/mobile-workspace.md).

### Architecture
Android services use a factory pattern with `createAndroidServiceNode()` for consistent node structure:
- **SquareNode Component**: Visual representation with configuration status indicators
- **Dynamic Actions**: Load available actions from backend via `loadOptionsMethod`
- **ADB Integration**: All services communicate with Android devices via ADB commands
- **Parameter System**: Flexible JSON parameters for service-specific configuration

### Adding New Android Services

**Wave 11+**: Android service nodes are authored as backend plugins
under `server/nodes/android/<service>.py`. Each plugin subclasses the
shared `AndroidServiceBase` (see `server/nodes/android/_base.py`), which
handles ADB dispatch via `SERVICE_ID_MAP`. See the
[Plugin development guide](./docs-internal/plugin_system.md).

Adding a new Android service:

1. **Create the plugin** at `server/nodes/android/<service_name>.py`
   subclassing `AndroidServiceBase` — the base handles `service_id`
   routing, argument translation, and broadcast status updates.
2. **Register the service id** in `SERVICE_ID_MAP` on
   `server/nodes/android/_base.py` (camelCase node type → snake_case
   service id).
3. **Implement the execution path** in the plugin's `execute` method;
   shared ADB infrastructure lives in `AndroidService`.

### Key Files
- **Shared base**: `server/nodes/android/_base.py` — `AndroidServiceBase`, `SERVICE_ID_MAP`, `execute_android_service_tool`
- **Backend Router**: `server/nodes/android/_router.py` - plugin-owned API endpoints, mounted by the `register_router` loop in `main.py`
- **Workflow Handler**: `server/services/workflow.py` - Execution logic for all nodes
- **Execution Service**: `src/services/executionService.ts` - Routes Android nodes to Python backend

### Requirements
- **Device Connection**: Configure Android connection via Credentials Modal (Android panel)
- **Permissions**: Android app must have necessary permissions for services

### Android Device Connection
Android device connection is configured via the **Credentials Modal** (Android panel), not via workflow nodes.

**Connection Types:**
1. **Remote Relay** (recommended): Connect to Android device via relay server (QR code pairing)
2. **Local ADB**: Connect via USB with ADB port forwarding

**WebSocket Handlers** (`server/routers/websocket.py`):
- `android_relay_connect` - Connect to relay server, get QR code for pairing
- `android_relay_disconnect` - Disconnect from relay server
- `android_relay_reconnect` - Reconnect to relay server

### Android Relay Client
Located in `server/nodes/android/_relay/`:

**Key Components:**
- `client.py` - RelayWebSocketClient manages persistent connection
- `broadcaster.py` - Status broadcast functions (connected, paired, disconnected)
- `manager.py` - Global client instance management
- `protocol.py` - JSON-RPC 2.0 message handling

**Message Filtering:**
```python
async def receive_message(self, timeout: float = 10.0):
    """Receive response message, skipping non-response types"""
    skip_types = {'presence', 'pong', 'ping', 'connected'}

    while True:
        data = await asyncio.wait_for(self._message_queue.get(), timeout)
        msg_type = data.get('type', '')

        if msg_type in skip_types:
            continue  # Skip and wait for next message

        return data  # Return actual response
```

**Performance Benefits:**
- Initial connection: ~0.18s (WebSocket handshake + registration)
- Reused connection: ~0.0003s (600x faster)
- Background tasks maintain connection health
- Message queue decouples receiving from service execution

### Android Relay Connection vs Device Pairing

The Android relay system uses a **two-state model** for connection status:

| State | Description | Frontend Indicator |
|-------|-------------|-------------------|
| `connected` | WebSocket connection to relay server is active | N/A (not shown directly) |
| `paired` | Android device has scanned QR and is paired via relay | Green/Red status dot |

**Key Concepts:**
- **Relay Connection**: The WebSocket connection to `wss://relay.opencompany.sh/ws` - can be active without a device
- **Device Pairing**: An Android device scans the QR code and pairs - required for service execution
- **Android service nodes require pairing**, not just relay connection, to execute

**Status Broadcasting Architecture:**
```
server/nodes/android/_relay/
├── client.py        # RelayWebSocketClient - manages WebSocket connection
├── broadcaster.py   # Status broadcast functions
├── manager.py       # Global client instance management
└── protocol.py      # JSON-RPC 2.0 message handling
```

**Broadcast Functions** (`server/nodes/android/_relay/broadcaster.py`):
```python
# Device connected and paired
await broadcast_connected(device_id, device_name)

# Device disconnected but relay still connected (for re-pairing)
await broadcast_device_disconnected(
    relay_connected=True,
    qr_data=qr_data,
)

# Relay connection fully closed
await broadcast_relay_disconnected()

# QR code available for pairing
await broadcast_qr_code(qr_data)
```

**Frontend Status Indicator** (`client/src/components/SquareNode.tsx`):
```typescript
// Android nodes use 'paired' status, not 'connected'
const isAndroidConnected = isAndroidNode && androidStatus.paired;
```

**Status Flow:**
1. User clicks "Connect" → Relay WebSocket connects → `connected=true, paired=false`
2. QR code displayed → User scans with Android app → `connected=true, paired=true`
3. Android app disconnects → `connected=true, paired=false` (can re-pair)
4. Relay WebSocket closes → `connected=false, paired=false`

**WebSocket Context Interface** (`client/src/contexts/WebSocketContext.tsx`):
```typescript
export interface AndroidStatus {
  connected: boolean;      // Relay WebSocket connected
  paired: boolean;         // Android device paired
  device_id: string | null;
  device_name: string | null;
  connected_devices: string[];
  connection_type: string | null;
  qr_data: string | null;
  session_token: string | null;
}
```

## WhatsApp Integration

### Overview
WhatsApp nodes use square design with integrated QR code viewing and proper error handling. The integration proxies all requests through the Python backend to the WhatsApp RPC service (port from `WHATSAPP_RPC_PORT`, or the `--port` CLI flag). Supports individual chats, groups, and newsletter channels (sending, querying, follow/unfollow, create, mute, mark viewed, react, live updates, media download, profile pics). All 14 WhatsApp events handled.

### Architecture
```
Frontend (useWhatsApp / WebSocket) -> Python backend WS handlers (nodes/whatsapp/_handlers.py) -> WhatsApp RPC service (localhost:${WHATSAPP_RPC_PORT})
```

### Key Features
- **Square Node Design**: 80x80px square nodes with status indicators
- **QR Code Viewer**: Embedded QR code display via Python backend proxy
- **Error Handling**: Robust error handling with proper HTTP status codes (503, 504, 410)
- **No Mock Data**: All endpoints return proper errors instead of mock responses
- **Connection Status**: Real-time status display with device ID, session, and service info

### Backend Helpers (`server/nodes/whatsapp/_service.py`)

Wave 11 renamed `routers/whatsapp.py` (misnamed — never an APIRouter) to `services/whatsapp_service.py`; Wave 11.I then moved it into the plugin folder as `nodes/whatsapp/_service.py`. Provides RPC proxy helpers consumed by `nodes/whatsapp/*` plugins and the WhatsApp WebSocket handlers. <!-- docs-lint: ignore; retired paths are recorded here as migration history. -->

#### `whatsapp_status` - Get Connection Status
- Returns WhatsApp connection status from Flask service
- Handles ConnectError, TimeoutException with 503/504 status codes
- Safe JSON parsing with error handling

#### `whatsapp_qr` - Get QR Code
- Checks connection status first
- Returns QR code data if not connected
- Returns "Already connected" message if connected
- Handles errors gracefully without crashing

#### `whatsapp_start` - Start Connection
- Proxies start request to Flask service
- Safe JSON parsing and error handling
- Returns proper HTTP errors on failure

#### `whatsapp_send` - Send Message
- Enhanced messaging endpoint
- Comprehensive error handling with specific exception catches
- Never crashes on service unavailability

### Frontend (SquareNode.tsx via NodeSpec component_kind; QR panel under components/credentials/)
- **Canvas node**: `client/src/components/SquareNode.tsx` renders the WhatsApp plugins from their backend NodeSpec (`component_kind = "square"`; `whatsappReceive` is `"trigger"`) and reads live connection state through `useWhatsAppStatus()` -- there is no WhatsApp-specific node component
- **QR / connection panel**: `client/src/components/credentials/panels/QrPairingPanel.tsx` (mounted by `PanelRenderer.tsx`) -- Start / Restart / Refresh actions call `useWhatsApp()`'s `startConnection` / `restartConnection` / `getStatus`, which send `whatsapp_start` / `whatsapp_restart` / `whatsapp_status`; the QR image comes from `whatsapp_qr`
- **Requests**: every call is a WebSocket `sendRequest` (see `WebSocketContext.tsx`); no `/api/whatsapp/*` REST routes exist

### Critical Bug Fixes

#### 1. Missing Dependency Injection Wiring
**Problem**: `main.py` was missing `"routers.whatsapp"` in `container.wire()` modules list
**Impact**: Uvicorn reloader child process crashed with exit code 1, triggering SIGTERM
**Fix**: Added `"routers.whatsapp"` to wiring list in `server/main.py`

(Historical snippet — the router set has since changed: `routers.whatsapp` /
`routers.maps` / `routers.android` moved into plugin folders (Wave 11.I) and
`routers.nodejs_compat` was deleted (July 2026). The live wire list is in
`server/main.py`. The lesson stands: every wired module must be listed or the
reloader child crashes.)

#### 2. Unhandled JSON Parse Errors
**Problem**: `.json()` calls without error handling raised `JSONDecodeError` when Flask returned HTML errors
**Impact**: Server crashes when WhatsApp service unavailable
**Fix**: Wrapped all `.json()` calls in try-except blocks with proper error responses

#### 3. Unhandled HTTP Status Errors
**Problem**: `response.raise_for_status()` raised `httpx.HTTPStatusError` not caught by specific handlers
**Impact**: Unhandled exceptions crashed the server
**Fix**: Removed `.raise_for_status()`, manually check `response.status_code != 200`

#### 4. Missing HTTPException Re-raise
**Problem**: Generic `Exception` handlers didn't re-raise `HTTPException`
**Impact**: Double exception wrapping and unclear errors
**Fix**: Added `except HTTPException: raise` before generic handler

### Error Handling Pattern
All WhatsApp endpoints follow this pattern:
```python
try:
    response = await client.get(url, timeout=10.0)

    # Check status manually
    if response.status_code != 200:
        raise HTTPException(status_code=503, detail="...")

    # Safe JSON parsing
    try:
        data = response.json()
        return data
    except Exception as json_err:
        logger.error(f"Failed to parse JSON: {json_err}")
        raise HTTPException(status_code=503, detail="Invalid response")

except httpx.ConnectError as e:
    raise HTTPException(status_code=503, detail="Service not running")
except httpx.TimeoutException as e:
    raise HTTPException(status_code=504, detail="Service timeout")
except HTTPException:
    raise  # Re-raise HTTPException
except Exception as e:
    logger.error(f"Unexpected error: {e}")
    raise HTTPException(status_code=503, detail="Service unavailable")
```

### Result
- Python backend never crashes when WhatsApp service is down
- Proper HTTP error codes (503, 504, 410) returned
- No SIGTERM crashes
- Frontend receives proper error messages
- QR code viewer works seamlessly
- All mock data removed from production code

### WhatsApp Group/Sender Name Persistence
The WhatsApp Receive node stores human-readable names alongside JIDs/phone numbers:

#### Problem
When reopening the parameter panel, group/sender selectors showed the raw JID (e.g., `120363123456789@g.us`) instead of the group name because the name was only fetched when the dropdown was opened.

#### Solution
Store the name as a separate parameter alongside the ID:
- `group_id` + `group_name` - Group JID and display name
- `phone_number` + `sender_name` - Phone number and contact name

#### Implementation
```typescript
// In ParameterRenderer.tsx - GroupIdSelector
<GroupIdSelector
  value={currentValue || ''}
  onChange={onChange}
  onNameChange={(name) => onParameterChange?.('group_name', name)}
  storedName={allParameters?.group_name || ''}
  ...
/>

// GroupIdSelector stores name when selection changes
const handleChange = (value: string, option: any) => {
  onChange(value);
  if (option?.label && onNameChange) {
    onNameChange(option.label);
  }
};

// Display uses storedName when available
const displayLabel = storedName || (value && !loading ? value : '');
```

#### Key Files
- `client/src/components/ParameterRenderer.tsx` - GroupIdSelector and SenderNumberSelector with `onNameChange` and `storedName` props
- `client/src/components/parameterPanel/MiddleSection.tsx` - Passes `onParameterChange` to ParameterRenderer

## Event-Driven Trigger Node System

### Overview
Trigger nodes wait for external events (WhatsApp messages, webhooks, etc.) using Python's asyncio.Future. The backend handles all event waiting logic with the frontend displaying waiting state and providing cancel functionality.

### Architecture
```
User clicks "Run" on Trigger Node
       ↓
Frontend sends execute_node via WebSocket
       ↓
Python backend detects trigger node type (event_waiter.is_trigger_node)
       ↓
Backend registers asyncio.Future waiter with filter
       ↓
Backend broadcasts "waiting" status to frontend
       ↓
External service sends event (e.g., whatsapp_message_received)
       ↓
event_waiter.dispatch() resolves matching waiters
       ↓
Backend returns execution result with event data as output
       ↓
Frontend displays result in output panel
```

### Backend Implementation

#### Event Waiter Service (`server/services/event_waiter.py`)
Generic event waiting using standard asyncio primitives:

```python
@dataclass
class TriggerConfig:
    node_type: str
    event_type: str  # e.g., 'whatsapp_message_received'
    display_name: str

TRIGGER_REGISTRY: Dict[str, TriggerConfig] = {
    # Framework-level triggers — not owned by any plugin domain.
    "start": TriggerConfig(node_type="start", event_type="deploy_triggered", display_name="Deploy Start"),
    "webhookTrigger": TriggerConfig(node_type="webhookTrigger", event_type="webhook_received", display_name="Webhook Request"),
    "chatTrigger": TriggerConfig(node_type="chatTrigger", event_type="chat_message_received", display_name="Chat Message"),
    "taskTrigger": TriggerConfig(node_type="taskTrigger", event_type="task_completed", display_name="Task Completed"),
    # plugin-owned entries are backfilled by _auto_populate_from_plugins()
}

@dataclass
class Waiter:
    id: str = field(default_factory=lambda: str(uuid.uuid4()))
    node_id: str = ""
    node_type: str = ""
    event_type: str = ""
    filter_fn: Callable[[Dict], bool] = field(default_factory=lambda: lambda x: True)
    future: Optional[asyncio.Future] = None
    cancelled: bool = False
    created_at: float = field(default_factory=time.time)

# Key functions:
async def register(node_type: str, node_id: str, params: Dict) -> Waiter
def dispatch(event_type: str, data: Dict) -> int  # Returns count resolved
def cancel(waiter_id: str) -> bool
def cancel_for_node(node_id: str) -> int
def get_active_waiters() -> List[Dict]
```

#### Trigger Node Execution (`server/services/handlers/triggers.py`)
```python
async def handle_trigger_node(node_id: str, node_type: str, parameters: Dict, context: Dict) -> Dict:
    config = event_waiter.get_trigger_config(node_type)
    waiter = await event_waiter.register(node_type, node_id, parameters)

    # Broadcast waiting status
    await broadcaster.update_node_status(node_id, "waiting", {
        "message": f"Waiting for {config.display_name}...",
        "waiter_id": waiter.id
    })

    # Wait indefinitely (user cancels via cancel_event_wait)
    event_data = await waiter.future
    return {"success": True, "result": event_data, ...}
```

#### Filter Builders
Each trigger type has a filter builder that creates a function to match events:

```python
def build_whatsapp_filter(params: Dict) -> Callable[[Dict], bool]:
    """Build filter for WhatsApp messages based on node parameters."""
    msg_type = params.get('messageTypeFilter', 'all')
    sender_filter = params.get('filter', 'all')  # all, any_contact, contact, group, keywords
    forwarded_filter = params.get('forwardedFilter', 'all')  # all, only_forwarded, ignore_forwarded
    # ... builds closure that checks message fields
```

**Sender Filter Options:**
- `all` - Accept all messages (groups and contacts)
- `any_contact` - Accept only non-group messages (individual chats)
- `contact` - Accept from specific phone number
- `group` - Accept from specific group (optionally filter by sender)
- `keywords` - Accept messages containing specific keywords

### WebSocket Handlers

#### Cancel Event Wait (`server/routers/websocket.py`)
```python
@ws_handler()
async def handle_cancel_event_wait(data: Dict[str, Any], websocket: WebSocket):
    """Cancel by waiter_id or node_id."""
    if waiter_id := data.get("waiter_id"):
        success = event_waiter.cancel(waiter_id)
    elif node_id := data.get("node_id"):
        count = event_waiter.cancel_for_node(node_id)
    return {"success": success, ...}

@ws_handler()
async def handle_get_active_waiters(data: Dict[str, Any], websocket: WebSocket):
    """Get list of active waiters for debugging/UI."""
    return {"waiters": event_waiter.get_active_waiters()}
```

### WhatsApp Receive Node

#### Node Definition (plugin: `server/nodes/whatsapp/whatsapp_receive.py`; pre-Wave-11 frontend shape shown below for historical reference)
```typescript
whatsappReceive: {
  displayName: 'WhatsApp Receive',
  name: 'whatsappReceive',
  icon: WHATSAPP_RECEIVE_ICON,  // Bell with notification dot
  group: ['whatsapp', 'trigger'],
  outputs: [{
    name: 'main',
    displayName: 'Message',
    type: 'main',
    description: 'message_id, sender, chat_id, message_type, text, timestamp, is_group, is_from_me, push_name, group_info'
  }],
  properties: [
    // Message Type Filter: all, text, image, video, audio, document, location, contact
    // Sender Filter: all, contact (specific phone), group (specific group), keywords
    // Ignore Own Messages: boolean (default true)
    // Include Media Data: boolean (default false)
  ]
}
```

#### Output Schema (plugin-owned, `server/nodes/whatsapp/whatsapp_receive.py`)
Runtime output shapes are fetched lazily by InputSection per the Wave 3 source-of-truth decision. WhatsApp's schemas live in the plugin folder (the node's `Output` Pydantic classes — `WhatsAppGroupInfo` / `WhatsAppReceiveOutput` / `WhatsAppSendOutput` / `WhatsAppDbOutput`) and self-register from `nodes/whatsapp/__init__.py` via `register_output_schema(...)` — same pattern as telegram; `services/node_output_schemas.py` carries no whatsapp code.
Served via `GET /api/schemas/nodes/whatsappReceive.json` + `get_node_output_schema` WS handler. See [docs-internal/plugin_system.md](./docs-internal/plugin_system.md) (the original RFC is archived at [ARCHIVE/schema_source_of_truth_rfc.md](./docs-internal/ARCHIVE/schema_source_of_truth_rfc.md)).

### Task Trigger Node

The Task Trigger node fires when a delegated child agent completes its task (success or error). This enables parent agents to react to child completion via workflow nodes.

#### Node Definition (plugin: `server/nodes/trigger/task_trigger/__init__.py`; pre-Wave-11 frontend shape shown below for historical reference)
```typescript
taskTrigger: {
  displayName: 'Task Completed',
  name: 'taskTrigger',
  icon: '📨',
  group: ['trigger', 'workflow'],
  outputs: [{
    name: 'main',
    displayName: 'Output',
    type: 'main',
    description: 'task_id, status, agent_name, result/error, parent_node_id'
  }],
  properties: [
    // Task ID Filter: Optional specific task ID to watch
    // Agent Name Filter: Optional partial match on agent name
    // Status Filter: all, completed, error
    // Parent Node ID: Optional filter by parent agent node
  ]
}
```

#### Output Schema (`client/src/components/parameterPanel/InputSection.tsx`)
```typescript
taskTrigger: {
  task_id: 'string',
  status: 'string',      // 'completed' or 'error'
  agent_name: 'string',
  agent_node_id: 'string',
  parent_node_id: 'string',
  result: 'string',      // Present when status='completed'
  error: 'string',       // Present when status='error'
  workflow_id: 'string',
}
```

#### Event Dispatch (`server/services/handlers/tools.py`)
The `task_completed` event is dispatched when a delegated child agent finishes:
```python
# On success:
await broadcaster.send_custom_event('task_completed', {
    'task_id': task_id,
    'status': 'completed',
    'agent_name': agent_label,
    'agent_node_id': node_id,
    'parent_node_id': config.get('parent_node_id', ''),
    'result': result.get('result', {}).get('response', ...),
    'workflow_id': workflow_id,
})

# On error:
await broadcaster.send_custom_event('task_completed', {
    'task_id': task_id,
    'status': 'error',
    'agent_name': agent_label,
    'agent_node_id': node_id,
    'parent_node_id': config.get('parent_node_id', ''),
    'error': str(e),
    'workflow_id': workflow_id,
})
```

### Adding New Trigger Types

1. **Add to Registry** in `server/services/event_waiter.py`:
   ```python
   TRIGGER_REGISTRY['emailTrigger'] = TriggerConfig('emailTrigger', 'email_received', 'Email')
   ```

2. **Add Filter Builder**:
   ```python
   def build_email_filter(params: Dict) -> Callable[[Dict], bool]:
       # Build filter based on node parameters
   FILTER_BUILDERS['emailTrigger'] = build_email_filter
   ```

3. **Add the plugin** at `server/nodes/<category>/<trigger_name>.py`:
   - Define the `NodeSpec` (inputs / outputs / uiHints) as a subclass
     or dataclass per the plugin system
   - Implement the trigger-handler `execute` method

4. **Add Output Schema** in `InputSection.tsx`:
   ```typescript
   email: { from: 'string', subject: 'string', body: 'string', ... }
   ```

5. **Dispatch Events** from external service:
   ```python
   from services import event_waiter
   event_waiter.dispatch('email_received', email_data)
   ```

### Polling Triggers (Gmail, Outlook, IMAP, Twitter)

Some triggers require active API polling instead of waiting for externally dispatched events. These use `setup_polling_trigger` in `TriggerManager` instead of `setup_event_trigger`.

**Architecture:**
```
setup_polling_trigger() → broadcasts "waiting" status
       ↓
   poller task: runs poll_coroutine(queue, is_running_fn)
       ↓                    ↓
   polls API at interval → enqueues new items to asyncio.Queue
       ↓
   processor task: reads queue → calls on_event → spawns execution run
```

**Key differences from event triggers:**
- Event triggers: `event_waiter.register()` + `wait_for_event()` (push-based)
- Polling triggers: Custom poll coroutine + `asyncio.Queue` (pull-based)

**Routing** (`server/services/deployment/manager.py`):
```python
if node_type in POLLING_TRIGGER_TYPES:  # googleGmailReceive, twitterReceive, emailReceive, msMailReceive
    poll_coroutine = self._create_poll_coroutine(node_type, node_id, params)
    await trigger_manager.setup_polling_trigger(...)
```

**Constants** (`server/constants.py`):
- `POLLING_TRIGGER_TYPES`: `frozenset({'googleGmailReceive', 'twitterReceive', 'emailReceive', 'msMailReceive'})`
- These are also in `WORKFLOW_TRIGGER_TYPES` for trigger node detection

### Key Design Decisions

- **No Timeout**: Trigger nodes wait indefinitely; users cancel via Cancel button
- **Backend-First**: All event waiting logic in Python backend, minimal frontend changes
- **Generic Architecture**: Same execution flow for all trigger types via registry
- **Filter Functions**: Each trigger type builds its own filter from node parameters
- **asyncio.Future**: Simpler than asyncio.Event for single-value resolution
- **Polling triggers**: Use asyncio.Queue + dedicated poll coroutine for APIs without push support

## Real-time Status WebSocket System

### Overview
The frontend and Python backend communicate via WebSocket for real-time status updates. This replaces API polling with push-based updates for Android connection status, node execution status, and variable changes.

### Architecture
```
React Frontend (WebSocketContext.tsx) <--WebSocket--> Python Backend (status_broadcaster.py)
         |                                                    |
         v                                                    v
   SquareNode.tsx                                   nodes/android/_relay/
   (uses androidStatus)                             (broadcasts Android status)
```

### Backend Implementation

#### Status Broadcaster (`server/services/status_broadcaster.py`)
Central service for managing WebSocket connections and broadcasting status updates:

```python
class StatusBroadcaster:
    def __init__(self):
        self._connections: Set[WebSocket] = set()
        self._status: Dict[str, Any] = {
            "android": {
                "connected": False,
                "paired": False,
                "device_id": None,
                "device_name": None,
                "connected_devices": [],
                "connection_type": None,
                "qr_data": None,
                "session_token": None,
            },
            # No "whatsapp" seed: the plugin owns its slot outright
            # (nodes/whatsapp/_events.py writes the full dict) and the FE
            # guards on key presence — absent means disconnected.
            "twitter": {"connected": False, "username": None, "user_id": None, "name": None, "profile_image_url": None},
            "google": {"connected": False, "email": None, "name": None},
            "telegram": {"connected": False, "bot_id": None, "bot_username": None, "bot_name": None},
            "api_keys": {},  # provider -> validation status
            "nodes": {},  # node_id -> node status
            "variables": {},  # variable_name -> value
            "workflow": {"executing": False, "current_node": None},
            "workflow_lock": {"locked": False, "workflow_id": None, "locked_at": None, "reason": None},
            "deployment": {"isRunning": False, "activeRuns": 0, "status": "idle", "workflow_id": None},
        }

    async def connect(self, websocket: WebSocket): ...
    async def disconnect(self, websocket: WebSocket): ...
    async def update_android_status(self, connected, device_id, connected_devices, connection_type): ...
    async def update_node_status(self, node_id, status, data): ...
    async def update_variable(self, name, value): ...
    async def update_workflow_status(self, executing, current_node, progress): ...
```

Key methods:
- `connect()` - Accepts WebSocket, adds to connection set, sends initial status
- `update_android_status()` - Updates Android status and broadcasts to all clients
- `update_node_status()` - Updates individual node status with data/output
- `update_variable()` - Updates single variable value
- `_broadcast()` - Sends message to all connected clients

#### WebSocket Router (`server/routers/websocket.py`)
FastAPI WebSocket endpoint:

```python
@router.websocket("/ws/status")
async def websocket_status_endpoint(websocket: WebSocket):
    broadcaster = get_status_broadcaster()
    await broadcaster.connect(websocket)
    try:
        while True:
            data = await websocket.receive_json()
            if data.get("type") == "ping":
                await websocket.send_json({"type": "pong"})
            elif data.get("type") == "get_status":
                await websocket.send_json({"type": "full_status", "data": broadcaster.get_status()})
    except WebSocketDisconnect:
        await broadcaster.disconnect(websocket)
```

### Frontend Implementation

#### WebSocket Context (`client/src/contexts/WebSocketContext.tsx`)
React context providing WebSocket connection and status state:

```typescript
export interface AndroidStatus {
  connected: boolean;
  device_id: string | null;
  connected_devices: string[];
  connection_type: string | null;
}

export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [androidStatus, setAndroidStatus] = useState<AndroidStatus>(defaultAndroidStatus);
  const [nodeStatuses, setNodeStatuses] = useState<Record<string, NodeStatus>>({});
  const [variables, setVariables] = useState<Record<string, any>>({});
  // WebSocket connection with auto-reconnect
};

// Hooks for consuming status
export const useWebSocket = (): WebSocketContextValue => { ... }
export const useAndroidStatus = (): AndroidStatus => { ... }
export const useNodeStatus = (nodeId: string): NodeStatus => { ... }
```

Features:
- Auto-connect on mount
- Reconnect with exponential backoff through PartySocket (`WS_RECONNECT` in `client/src/lib/connectionConfig.ts`, no jitter); a remote close, even with code 1000, keeps retrying, and only logout or unmount (`disposeConnection`) stops it
- App heartbeat (`client/src/lib/webSocketHeartbeat.ts`): a ping every 30 s while the page is visible; no pong within 10 s forces a reconnect
- Message type handlers for all status update types

#### Usage in Components (`client/src/components/SquareNode.tsx`)
```typescript
const { androidStatus } = useWebSocket();
// Android service nodes use 'paired' status (device must be paired to execute)
const isAndroidConnected = isAndroidNode && androidStatus.paired;
```

### Android Status Broadcasting
The Android relay client (`server/nodes/android/_relay/client.py`) broadcasts status changes via dedicated functions in `broadcaster.py`:

```python
# When device pairs successfully
await broadcast_connected(device_id, device_name)

# When device unpairs (relay may still be connected)
await broadcast_device_disconnected(
    relay_connected=self.is_connected(),
    qr_data=self.qr_data,
)

# When relay WebSocket closes unexpectedly
await broadcast_relay_disconnected()
```

**Key distinction:**
- `broadcast_device_disconnected()` - Device unpaired, relay still connected (can re-pair via QR)
- `broadcast_relay_disconnected()` - Full disconnection, need to reconnect

### Real Device Detection
Fixed issue where Android status remained green after device disconnect:

**Problem**: Base name `android_system_services` remained in connected devices set after real device `android_system_services_1764708352672` left.

**Solution**: Added methods to distinguish real devices (with timestamp suffix) from base names:

```python
def _get_discovered_devices(self) -> list:
    """Get list of actual discovered devices (with timestamp suffix)."""
    discovered = []
    for device_id in self._connected_android_devices:
        parts = device_id.rsplit('_', 1)
        if len(parts) == 2 and parts[1].isdigit():
            discovered.append(device_id)
    return discovered

def has_real_android_devices(self) -> bool:
    """Check if there are any real (discovered) Android devices connected."""
    return len(self._get_discovered_devices()) > 0
```

Updated `client_left` and presence handlers use `has_real_android_devices()` instead of checking total device count.

### WebSocket Message Types

> Live count = `len(MESSAGE_HANDLERS) + len(get_ws_handlers())` (core dict in `server/routers/websocket.py` + plugin-registered handlers). The catalogue below is illustrative, not exhaustive or hand-maintained.

#### Request/Response Messages (Client -> Server -> Client)
| Category | Message Types |
|----------|--------------|
| **Status/Ping** | `ping`, `get_status`, `get_android_status`, `get_node_status`, `get_variable` |
| **Node Parameters** | `get_node_parameters`, `get_all_node_parameters`, `save_node_parameters`, `delete_node_parameters` |
| **Tool Schemas** | `get_tool_schema`, `save_tool_schema`, `delete_tool_schema`, `get_all_tool_schemas` |
| **Node Execution** | `execute_node`, `execute_workflow`, `cancel_execution`, `get_node_output`, `clear_node_output` |
| **Triggers/Events** | `cancel_event_wait`, `get_active_waiters` |
| **Dead Letter Queue** | `get_dlq_entries`, `get_dlq_entry`, `get_dlq_stats`, `replay_dlq_entry`, `remove_dlq_entry`, `purge_dlq` |
| **Deployment** | `deploy_workflow` (Start's own step; refused without a live generation; arms the graph the generation recorded, never the request's), `cancel_deployment`, `get_deployment_status`, `get_workflow_lock`, `update_deployment_settings` |
| **AI Operations** | `execute_ai_node`, `get_ai_models` |
| **API Keys** | `validate_api_key`, `get_stored_api_key`, `save_api_key`, `delete_api_key` |
| **CLI agent auth** | `claude_code_login`, `claude_code_submit_code`, `claude_code_status`, `claude_code_logout`, `codex_cli_login`, `codex_cli_device_login`, `codex_cli_status`, `codex_cli_logout` |
| **Twitter OAuth** | `twitter_oauth_login`, `twitter_oauth_status`, `twitter_logout` |
| **Google OAuth** | `google_oauth_login`, `google_oauth_status`, `google_logout` |
| **Discord / Microsoft OAuth** | `discord_oauth_login`, `discord_oauth_status`, `discord_logout`, `microsoft_oauth_login`, `microsoft_oauth_status`, `microsoft_logout` (all minted by `services.events.oauth_lifecycle.make_oauth_lifecycle_handlers`) |
| **Android** | `get_android_devices`, `execute_android_action`, `android_relay_connect`, `android_relay_disconnect`, `android_relay_reconnect` |
| **WhatsApp** | `whatsapp_status`, `whatsapp_connected_phone`, `whatsapp_qr`, `whatsapp_send`, `whatsapp_start`, `whatsapp_restart`, `whatsapp_groups`, `whatsapp_group_info`, `whatsapp_chat_history`, `whatsapp_newsletters`, `whatsapp_rate_limit_get`, `whatsapp_rate_limit_set`, `whatsapp_rate_limit_stats`, `whatsapp_rate_limit_unpause`, `whatsapp_mark_read`, `whatsapp_typing`, `whatsapp_presence`, `whatsapp_stop`, `whatsapp_diagnostics` |
| **Telegram** | `telegram_connect`, `telegram_disconnect`, `telegram_status`, `telegram_send`, `telegram_reconnect`, `telegram_get_me`, `telegram_get_chat` |
| **Workflow Storage** | `save_workflow`, `get_workflow`, `get_all_workflows`, `delete_workflow` |
| **Chat Messages** | `send_chat_message`, `get_chat_messages`, `clear_chat_messages`, `save_chat_message`, `chat_subscribe`, `chat_unsubscribe`, `get_chat_run`, `edit_chat_message`, `regenerate_chat_reply`, `switch_chat_branch`, `set_chat_feedback`, `get_chat_context`, `get_chat_models`, plus the speech plugin's `dictation_status` / `transcribe_audio` (see [chat_protocol.md](./docs-internal/chat_protocol.md)) |
| **Console/Terminal** | `get_console_logs`, `clear_console_logs`, `get_terminal_logs`, `clear_terminal_logs` |
| **User Skills** | `get_user_skills`, `get_user_skill`, `create_user_skill`, `update_user_skill`, `delete_user_skill` |
| **Built-in Skills** | `get_skill_content`, `save_skill_content`, `scan_skill_folder`, `list_skill_folders` |
| **Memory/Skill Reset** | `clear_memory`, `reset_skill` |
| **User Settings** | `get_user_settings`, `save_user_settings` |
| **Provider Defaults** | `get_provider_defaults`, `save_provider_defaults` |
| **Pricing** | `get_pricing_config`, `save_pricing_config` |
| **Usage/Compaction** | `get_api_usage_summary`, `get_compaction_stats`, `configure_compaction`, `get_provider_usage_summary` |
| **Agent Teams** | `create_team`, `get_team`, `get_team_status`, `dissolve_team`, `add_team_task`, `claim_team_task`, `complete_team_task`, `get_team_tasks`, `send_team_message`, `get_team_messages` |
| **Model Registry** | `get_model_constraints`, `refresh_model_registry` |
| **Employees (Normal mode)** | `list_employees`, `get_employee`, `get_employee_usage`, `generate_employee_setup`, `cancel_employee_setup`, `hire_employee`, `start_employee`, `enable_employee_talk`, `apply_employee_changes`, `rename_employee`, `set_employee_photo`, `workspace_steps_list` (see [normal_mode.md](./docs-internal/normal_mode.md)) |
| **Approvals** | `list_approvals`, `decide_approval` (the `approvalGate` node's drafts) |

#### Broadcast Messages (Server -> All Clients)
| Message Type | Description |
|--------------|-------------|
| `android_status` | Android device connection update |
| `node_status` | Node execution status change |
| `node_output` | Node execution output data |
| `agent_progress` | CloudEvents v1.0 envelope (type=`com.opencompany.agent.progress`) — per-step agent-loop iteration count, drives the live "N / max" badge on AI Agent canvas nodes |
| `agent_capability` | CloudEvents v1.0 envelope (`com.opencompany.agent.(skill|tool).*`) — exact-agent capability lifecycle with deterministic Temporal IDs, `(source,id)` deduplication, sanitized data, and an optional exact target node |
| `employee_lifecycle` | CloudEvents v1.0 envelope (type=`com.opencompany.employee.{hired,updated,removed}`, subject = workflow id) — Home's team list; `updated` is coalesced to one per second per employee, control changes go out at once |
| `approval_lifecycle` | CloudEvents v1.0 envelope (type=`com.opencompany.approval.{requested,decided,expired,cancelled}`) — identity only, never the draft or the recipient; the card refetches via `list_approvals` |
| `chat.updated` | CloudEvents v1.0 envelope (type=`com.opencompany.chat.updated`, data `{workflow_id, session_id, role}`) — sent directly (not `dispatch.emit`) by `services/chat_thread.py` after every chat insert and clear, and when a chat run ends; identity only, Home's thread and the editor's chat pane refetch via `get_chat_messages` |
| `chat_run_event` | CloudEvents v1.0 envelope (type=`com.opencompany.chat.run.<suffix>`, id `<run id>:<seq>`, subject = run id) — AG-UI run events, sent by `services/chat/hub.py` only to sockets subscribed to the session through `chat_subscribe`, never on the broadcast; see [chat_protocol.md](./docs-internal/chat_protocol.md) |
| `workflow_lifecycle` | CloudEvents v1.0 envelope for workflow stages (`deployment.*`, `lock.*`, `execution.*`, `imported`, `renamed`, `created`, `deleted`) — other tabs invalidate their workflow lists |
| `workspace_step` | CloudEvents v1.0 envelope (type=`com.opencompany.workspace.step`, subject = workflow id, data `{workflow_id, surface, step_id}`) — sent directly by `services/workspace_steps.py` when a Browser, Canvas or phone step is recorded; identity only, the Workspace dock refetches via `workspace_steps_list` for its status pill |
| `attention_lifecycle` | CloudEvents v1.0 envelope (type=`com.opencompany.attention.{opened,resolved}`, subject = the item's kind, data `{kind}`) — sent directly by `services/attention.py` when an attention item opens or is resolved; identity only, Home's attention line refetches via `list_attention_items` (and once a minute, since an item drops off an hour after it was last seen) |
| `deployment_snapshot` | CloudEvents v1.0 envelope (type=`workflow.deployment.snapshot`) — pushed once per WS connect from `broadcaster._send_deployment_snapshot`; lets the FE reconcile stale `deploymentStatus.isRunning=true` after a backend restart wiped `DeploymentManager._deployments`. Empty list is meaningful (forces reset). |
| `variable_update` | Single variable value change |
| `workflow_status` | Workflow execution progress |
| `api_key_status` | API key validation status |
| `node_parameters_updated` | Node parameters changed by another client |

#### Status Messages
| Message Type | Direction | Description |
|--------------|-----------|-------------|
| `initial_status` | Server -> Client | Full status on connect |
| `full_status` | Server -> Client | Full status response |
| `pong` | Server -> Client | Keep-alive response |
| `error` | Server -> Client | Error response with code and message |

## WebSocket Hooks

### useWhatsApp (`client/src/hooks/useWhatsApp.ts`)
Hook for WhatsApp operations via WebSocket:
```typescript
const { getStatus, getQRCode, sendMessage, startConnection, isLoading, connectionStatus } = useWhatsApp();
```

### Node execution (`client/src/services/executionService.ts`)
`ExecutionService.executeNodeViaWebSocket()` is the live path, called from
[`ParameterPanel.tsx`](./client/src/ParameterPanel.tsx). It delegates to the
context's `executeNode`, which sizes its request budget from the node's own
`uiHints.executionTimeoutMs` rather than any local list of "slow" node types.
(A `useExecution` hook once wrapped this; it was unreachable and was deleted.)

### useApiKeys (`client/src/hooks/useApiKeys.ts`)
Hook for API key management via WebSocket:
```typescript
const { validateApiKey, getStoredKey, saveApiKey, deleteApiKey } = useApiKeys();
```

### Android operations (no dedicated hook)
Android work goes through the WebSocket context directly — `getAndroidDevices` / `executeAndroidAction` / `androidStatus` off `useWebSocket()`, or a raw `sendRequest('android_relay_connect', …)`. A `useAndroidOperations` hook once wrapped those context methods; every live surface bypassed it, so it was deleted.

### useParameterPanel (`client/src/hooks/useParameterPanel.ts`)
Hook for parameter management via WebSocket:
```typescript
const { parameters, saveParameters, loadParameters, isDirty } = useParameterPanel(nodeId);
```

### Conditional Parameter Display Implementation
Located in `client/src/utils/parameterVisibility.ts` (imported by `client/src/components/parameterPanel/MiddleSection.tsx`):

```typescript
const shouldShowParameter = (param: INodeProperties, allParameters: Record<string, any>): boolean => {
  if (!param.displayOptions?.show) {
    return true;
  }

  const showConditions = param.displayOptions.show;

  for (const [paramName, allowedValues] of Object.entries(showConditions)) {
    const currentValue = allParameters[paramName];

    if (Array.isArray(allowedValues)) {
      if (!allowedValues.includes(currentValue)) {
        return false;
      }
    } else {
      if (currentValue !== allowedValues) {
        return false;
      }
    }
  }

  return true;
};
```

This function:
- Checks if parameter has displayOptions.show configuration
- Evaluates all show conditions against current parameter values
- Returns false if any condition fails (parameter hidden)
- Returns true if all conditions pass (parameter visible)
- Applied before rendering: `.filter(param => shouldShowParameter(param, parameters))`

## Planned Features

### Workflow-Level Execution (n8n-style Parallel Workflows)

**Current Limitations:**
- Single workflow execution at a time (global `_deployment_running` flag)
- Nodes fetch status on component mount, not when workflow is selected
- Status broadcasts to all clients without workflow filtering
- No isolation between workflow executions

**Planned Architecture:**

1. **Defer Node Status Checks Until Workflow Selected**
   - Status should only fetch when workflow containing those nodes is selected
   - Use cached status from WebSocket context instead of per-node fetching

2. **Workflow-Isolated Execution Context**
   ```python
   # server/services/workflow.py
   class ExecutionContext:
       def __init__(self, workflow_id: str, session_id: str):
           self.workflow_id = workflow_id
           self.session_id = session_id
           self.outputs: Dict[str, Any] = {}
           self.iteration = 0
           self.running = False
           self.task: Optional[asyncio.Task] = None

   # Replace single deployment state with:
   self._execution_contexts: Dict[str, ExecutionContext] = {}
   ```

3. **Parallel Workflow Deployment**
   - Each workflow gets unique `workflow_id` in execution requests
   - Backend tracks `_execution_contexts[workflow_id]` instead of single `_deployment_running`
   - Cancel by `workflow_id` instead of globally
   - Status broadcasts include `workflow_id` for client filtering

4. **Frontend Changes**
   - `WebSocketContext`: Add `activeWorkflowId`, filter status by workflow
   - `useAppStore`: Add `runningWorkflows: Set<string>` to track parallel executions
   - `WorkflowSidebar`: Show running indicator next to deployed workflows
   - `Dashboard`: Pass `workflow_id` to all execution calls

**Files to Modify:**
- `client/src/components/SquareNode.tsx` - Remove mount config check
- `client/src/contexts/WebSocketContext.tsx` - Add workflow filtering
- `client/src/store/useAppStore.ts` - Track running workflows
- `server/services/workflow.py` - ExecutionContext class, parallel support
- `server/routers/websocket.py` - workflow_id in messages
- `server/services/status_broadcaster.py` - workflow_id filtering

## Notes
- **No Legacy Support**: Pure modern methods only, backward compatibility removed
- **Interface Alignment**: ParameterRenderer supports both interface types seamlessly
- **Execution Ready**: Components can be executed with real-time result display
- **Clean Codebase**: Significant file and code reduction while maintaining full functionality
- **Modular Backend**: workflow.py reduced from 2068 lines to a facade (~840 lines today)
  - NodeExecutor: Registry-based dispatch with `functools.partial` for dependency injection
  - ParameterResolver: Compiled regex for `{{node.field}}` template resolution
  - DeploymentManager: Handles deploy/cancel lifecycle with TriggerManager for cron/events
  - No global state: `_active_cron_jobs` moved to TriggerManager instance variable
- **Performance**: Fast HMR updates and clean TypeScript compilation
- **AI Architecture**: 5-layer system with factory pattern and secure credential management
- **Android Architecture**: Factory-based node creation with ADB integration for device automation
- **WebSocket-First Architecture**: most frontend-backend RPC (parameters, execution, API keys, Android, WhatsApp, skill operations) goes through WebSocket. Live handler set lives in the `MESSAGE_HANDLERS` dict in `server/routers/websocket.py` plus plugin-registered handlers via `services.ws_handler_registry`.
- **WebSocket Hooks**: Dedicated React hooks (useWhatsApp, useApiKeys, useParameterPanel) for clean component integration; node execution goes through `services/executionService.ts`
- **WebSocket Support**: Persistent remote Android device connections via WebSocket proxy with background tasks
  - Connection stays alive across multiple API requests until switched to local ADB
  - Background message receiver and keepalive loop (25s interval)
  - Message queue for async message handling with filtering logic
  - Connection reuse reduces execution time from 0.18s to 0.0003s
- **Real-time Status WebSocket**: Frontend-backend WebSocket at `/ws/status` for live updates
  - Android connection status broadcasts when devices connect/disconnect
  - Node execution status and output updates
  - Variable value changes
  - Workflow execution progress
  - Replaces API polling with push-based updates
  - Auto-reconnect with exponential backoff plus an app-level heartbeat (see "WebSocket Reconnect via PartySocket" below)
  - Real device detection distinguishes actual devices (with timestamp suffix) from base names
  - **Android two-state model**: `connected` (relay WebSocket) vs `paired` (device paired)
    - Android service nodes use `paired` status for indicator (green = paired, red = not paired)
    - Relay can be connected without a device (shows QR for pairing)
    - Device disconnect broadcasts `paired=false` while keeping `connected=true` for re-pairing
- **Conditional Display**: Full implementation of displayOptions.show pattern for dynamic UI rendering
- **Process Management**: the Python CLI supervisor (`cli/supervisor.py`) owns child lifecycle; on Windows a Job Object kills the whole tree on exit, on POSIX `setsid` + `killpg`; `company stop` frees the ports named in `.env.template`
- **WhatsApp Integration**: Square node design with QR code viewer, proper error handling, no crashes
  - Critical fix: Added "routers.whatsapp" to dependency injection wiring
  - All endpoints use safe JSON parsing with comprehensive error handling
  - Backend proxies all requests to WhatsApp RPC service (`WHATSAPP_RPC_PORT`)
  - Returns proper HTTP status codes (503, 504, 410) instead of mock data
  - Python server never crashes when WhatsApp service is unavailable
  - WebSocket handlers for status, QR code, send message, and start connection
  - useWhatsApp hook provides clean React component integration
  - Uses external npm package `whatsapp-rpc` with pre-built Go binaries
  - Newsletter channels: send to channels (text/image/video/audio/document), query channel DB (list, info, messages, stats), follow/unfollow, create, mute, mark viewed, react, live updates. Channel JID format: `<numeric_id>@newsletter`. All 14 WhatsApp events handled (including `event.history_sync_complete`).
  - Media download: `include_media_data` param on chat_history and channel_messages triggers `media` RPC for base64 media retrieval
  - Channel message filters: date range (since/until), media type, text search, pagination offset
  - WebSocket RPC passthroughs: mark_read, typing, presence, stop, diagnostics
- **Event-Driven Triggers**: Generic trigger node architecture with asyncio.Future
  - `server/services/event_waiter.py` - Waiter registration, dispatch, cancellation
  - TRIGGER_REGISTRY for extensible trigger types (WhatsApp, Webhook, Telegram, future: Email, MQTT)
  - Filter builders create closures from node parameters (whatsapp_filter, webhook_filter)
  - No timeout - wait indefinitely until event or user cancel
  - WebSocket handlers: `cancel_event_wait`, `get_active_waiters`
  - **Trigger State Machine** (n8n p