Local Device Architecture
This document describes the technical architecture of local device support, including communication protocols, heartbeat mechanisms, and security design.
π Architecture Overviewβ
System Componentsβ
Device artifact declaration and downloadβ
Claude Code and Codex can declare final deliverables through the Wegent executor:
$WEGENT_EXECUTOR_BINARY artifact declare --path outputs/report.pdf
A declaration first verifies that the canonical file path stays inside the current Task workspace and computes the filename, relative path, canonical workspace root, size, modification time, and SHA-256.
A local Runtime conversation has no Backend Task. The executor leaves WEGENT_BACKEND_TASK_ID unset; declaration returns metadata and local_path without contacting Backend or requiring cloud credentials. The final response links to local_path with a Markdown file link, reusing the desktop file card and local preview. The link remains in conversation history while the file remains in its original workspace.
For linked Backend Tasks, WEGENT_BACKEND_TASK_ID must be a positive integer identifying the Backend API task, while WEGENT_TASK_ID retains the local conversation identity for workspace resolution. Declaration sends only the metadata above, never file contents. Authentication uses only the execution's AUTH_TOKEN (Task Token), not the separate runtime credential. Invalid Backend IDs fail before any request; a runtime-* conversation ID must never be sent to a cloud Task endpoint.
The executor working directory, WEGENT_TASK_WORKSPACE, and the declaration command share the same workspace path resolver. When a local device configures LOCAL_WORKSPACE_ROOT, the logical /workspace alias must not be treated as its physical workspace directory.
The updated default Ghost/default/wegent-wework requires successful declaration for linked Backend Tasks before directing users to conversation file cards. Local Runtime conversations follow the local declaration instructions injected at execution time. Initialization YAML does not overwrite existing Ghosts. Claude Code (with tools enabled) and Codex inject declaration instructions at execution time, so legacy Ghosts can use this delivery flow without rewriting database configuration. The prompt data migration and its no-op revision are removed without application-data reads or writes. Environments already recording a9c4e7b2d610 require separate version compatibility handling before deployment. Previously migrated prompts are not automatically restored. See the withdrawal and revision-chain notes.
Backend captures the executor name and namespace from the Task Token's assistant subtask and persists them with the workspace root as the artifact source. Downloads always use this source snapshot, never the task's latest executor. Recovery that rebinds the original subtask cannot redirect an old card to a new device. An unavailable original source remains unavailable; shared and copied tasks do not carry these device artifacts.
Device downloads reuse the canonical containment checks in workspace_read_file_chunk. HTTP executors use /filesystem/artifact, which requires an explicit root_path. Each read resolves the real path and verifies that it stays inside the original workspace, rejecting a file replaced with an outside symlink after declaration. An older executor without this endpoint fails explicitly; a rejected read must not fall back to /files. Bytes pass through Backend only when requested and are not persisted in Backend artifact storage. A card reads the current file in the original workspace, not a permanent historical copy.
Each artifact download is limited to 50 MiB. HTTP downloads check Content-Length before reading the body and count actual bytes as they arrive. Missing or understated lengths cannot bypass the limit: reading stops immediately with 413 when the limit is exceeded.
Wework Packaged App Local-First Channelβ
Local Electron packages reuse the release package's brand metadata and update source configuration. They must also include app-update.yml in Resources with updaterCacheDirName derived from the package name, allowing startup to migrate the actual updater cache directory. Write this file before signing the macOS app.
The packaged Wework Electron app defaults to local-first mode. This mode does not start the frontend Node dev server and does not start an extra local HTTP Backend service. The React UI runs inside the Electron renderer, while the Electron main process provides the app's internal command layer.
Local-first mode has three local runtime roles: the Electron main process, the executor sidecar, and core DSH:
Electron starts the executor sidecar with no arguments and exchanges newline-delimited JSON through a Unix socket or Windows named pipe unique to that App launch. Ordinary clients authenticate with the App IPC token. Electron uses a separate owner token for one connection that remains open for the lifetime of that executor generation. Disconnecting an ordinary client, including core DSH, closes only that connection; EOF on the owner connection closes the local endpoint and exits the executor. The executor therefore detects owner death even when Electron is killed with SIGKILL, crashes, or is force-quit before JavaScript cleanup can run.
Core DSH calls restricted host commands over inherited pipes created by Electron. An end or close on the input pipe means the Electron host no longer exists, so DSH invokes the launcher's appExit service. Normal client disposal removes those disconnect listeners first, preventing an orderly shutdown from being treated as owner death.
The Electron main process owns the executor and DSH processes it starts. On macOS and Linux, each runtime uses an isolated process group. An orderly App close or restart sends SIGTERM, continues waiting for the complete process group even if its leader has already exited, and sends SIGKILL to surviving members after the deadline. The dev-mode reload supervisor and the executor it launches remain in the same cleanup scope. Owner sockets and host pipes provide in-process self-termination for forced exits; process-group cleanup is the fallback for orderly shutdown, and neither mechanism replaces the other.
A local-endpoint write failure, EOF, or child exit marks the corresponding IPC connection unavailable. A normal request timeout ends only that request and does not tear down the transport, so system sleep or scheduling delays cannot trigger endpoint reconnection or attach the App to another executor. If the supervisor starts a new executor generation after a crash, Electron must complete a new owner handshake before that generation becomes ready.
Device commands started by the executor must have stdin closed instead of inheriting the process stdin that carries the App JSONL protocol. Otherwise, a child command that reads standard input can steal request bytes and corrupt the next protocol frame. The executor reads App IPC as bytes, splits frames on newlines, and validates UTF-8 per frame; an invalid frame is discarded after logging only its length and error offset and must not terminate the executor or interrupt unrelated running tasks. When Wework detects a changed executor runtime instance, it reloads the affected task list and transcripts. Queued messages confirmed by the transcript are removed, while sends that were still unconfirmed at disconnection return to a retryable queued state.
The local runtime event channel and App IPC write queues use bounded buffers with a capacity of 8192. IPC writing places Responses text, terminal events, errors, and RPC responses on a high-priority queue, while tool blocks, diagnostics, plans, and file-change events use a low-priority queue. A full high-priority queue applies explicit sender backpressure and records it. A full low-priority queue drops the current recoverable event, records the pressure, and emits executor.event_lagged instead of waiting for the broadcast channel to overwrite older events before detecting loss.
Tool-output events carry at most 64 KiB, and file-change events carry at most a 128 KiB diff preview; the complete patch remains available in its artifact. When Wework receives executor.event_lagged, it reloads transcripts for the current task and every running task, then reconciles those transcripts with optimistic user messages that have not yet been persisted by using the stable client message ID. Task switching or event congestion therefore converges execution state, user input, and assistant output from the same recovery result. An empty history output for the same tool block must not overwrite a live result already received. This applies to commands, Node REPL, and other MCP tools, retaining output truncation metadata and loading references.
Wework's DSH channel subscribes to executor events through a dedicated local endpoint connection. Event sequencing, reconnect replay, and history-loss detection are all executor responsibilities. The executor assigns a monotonically increasing sequence and retains an in-memory journal bounded by both 4096 events and 8 MiB. A reconnect supplies the last consumed sequence, and the executor atomically returns the subsequent journal snapshot before continuing with live events, avoiding a race between snapshotting and subscription. If the requested sequence is older than the retained window or ahead of the latest event, the executor emits executor.event_lagged with the event_history_lost reason so Wework can converge from transcripts.
The Electron layer does not retain an event journal, generate sequences, or implement replay and coalescing policy. It only bridges the executor's dedicated event socket to the SSE consumed by the renderer. If res.write() reports HTTP backpressure from the browser side, Electron closes the upstream socket and the current SSE so the renderer reconnects with the last consumed executor sequence. This prevents an unbounded Electron backlog while the screen is locked, the system is asleep, or the renderer is reloaded, and keeps stream correctness independent of Electron scheduling.
Backend connectivity is optional, not a required dependency for the local app. When login, model/capability sync, cloud projects, or web control of the local computer are needed, the executor can register as a local device over the Backend Socket.IO channel. The same executor sidecar reuses one command handler and one runtime work handler while serving Wework App and core DSH over the local App IPC endpoint and Backend over Socket.IO. This design does not introduce a local HTTP gateway and does not require Wework App to start Backend itself.
Original Codex Model Catalogβ
Wework model routing, Catalog editing, and save validation share one original Catalog baseline. A configured readable custom upstream supplies /models; when no upstream exists or its catalog is unavailable, the executor exports the complete bundled Catalog with the current Codex binary's debug models --bundled command and caches successful results for its process lifetime. Upstream catalog failures are logged without blocking providers that only support inference endpoints. Additional Wework models fill missing slugs without replacing original Codex fields.
User changes are stored as field differences in the device-local capabilities/model-catalog-overrides.json; restoring defaults removes those differences. Routing applies them to the original baseline before deriving vision delegation entries. Export failures report an error instead of substituting an incomplete Wework model list.
Cross-Component Request Log Correlationβ
Wework generates a request ID when a request enters a cross-process or cross-service boundary and reuses it in downstream logs for that request. This field diagnoses one request only. It does not replace task IDs, device IDs, thread IDs, or OpenTelemetry trace IDs, and it must not be used to infer business state.
- Renderer HTTP requests to Backend use
wework-http-<uuid>and pass it throughX-Request-ID. Backend reuses that value in its request context and returnsX-Request-IDin the response; CORS explicitly exposes the response header. - Renderer calls through core DSH to the local executor use
wework-local-<uuid>. The samerequest_idappears in DSH request-started, completed, or failed logs and in the executor'sruntime:rpcreceived and responded logs. - Wework calls to a remote executor through Backend Socket.IO use
cloud-runtime-<uuid>. Backend binds the value to the request context while handlingruntime:requestand copies it unchanged into the downstreamruntime:rpcenvelope; the remote executor continues logging the same value.
When no request ID exists, the protocol layer omits the request_id field instead of sending a null value, and each component uses its normal no-context log placeholder. Request IDs must be bounded printable diagnostic identifiers. Logs must not pair them with request bodies, authentication data, model keys, or local credentials.
To investigate a request, take the request_id from the Wework frontend, DSH, or Backend log near the user-visible failure, then search for that exact value in the same diagnostic directory or centralized log system. Start and finish entries also record the method, outcome, and elapsed time, so a missing boundary identifies the component where the request stopped without relying on approximate timestamps or task names.
MCP App Discovery and Request Failure Diagnosticsβ
An ordinary MCP tool invocation does not establish that a visual app exists. The conversation first uses discover on runtime.mcp_apps.request to validate the source invocation and the app resource identity from its tool catalog. A call without an app returns null, creates no app card, and retains no app connection. Once an app is confirmed, the native host explicitly retains its connection before initialize reads its resource. Discovery failures produce diagnostics only; resource loading failures for confirmed apps retain the error and retry controls.
executor-transport.log in the Electron log directory records local RPC request IDs, methods, elapsed time, and failure stages: fetch_failed, body_parse_failed, or rpc_failed. MCP discovery failures also record call IDs, task IDs, and tool status. Chromium local RPC network errors record the path, window identity, and net::ERR_* code without request bodies, cookies, or authentication data. Correlate request IDs with DSH and executor logs, then correlate window identity and time with Chromium errors. Failed to fetch alone does not establish a server business error or a specific network cause.
Each app instance has an independent sessionId retained and released by its native owner. On component close, window destruction, renderer exit, or main-frame navigation, the owner first runs cleanup callbacks to delete isolated documents, then waits for pending acquisition to settle before releasing the instanceβs connection. Late initialization cannot release a replacement instance. Each cloud instance owns an account-bound RPC client, disposed with the instance; an account switch cannot reuse its authenticated connection. Backend ownership follows the authenticated socket: disconnect releases retained instances, including acquisitions that complete after disconnect. Shutdown cleanup uses the hostβs standard five-second grace period; socket closure still triggers Backend cleanup. App retention and document lifetimes use no additional periodic heartbeat or expiring lease. After an executor restart, the next app operation restores retention against the current process identity.
The desktop CI mcp-apps checkpoint covers real app loading, resource failure recovery, ordinary historical calls, instance replacement after late initialization, session and document cleanup on reload, and the absence of cards after Chromium cancels a real discovery request, followed by network recovery. Fault injection is restricted to isolated E2E runs and never replaces Runtime responses.
Executor Startup Environment and Codex Home Initializationβ
Automatic credential synchronization applies cooldowns to credential attempts. When login or logout invalidates an old migration intent, plugin_auth_migration_expired must not suppress a fresh login on the next cycle. Other failures retain their cooldown to avoid repeated native authorization prompts. Synchronization logs contain only plugin and connector identifiers and result codes, never credentials or migration-intent tokens.
Git worktree sessions set PNPM_CONFIG_IGNORE_SCRIPTS=true and PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false, reusing pnpm's content cache while keeping the virtual dependency directory inside the worktree. Global virtual-store realpaths let TypeScript search the user's home directory for types and can introduce a second React version. Wework's TypeScript configuration also binds react and react/* to its own @types/react, so inputs outside workspace package directories, including shared JSON files, use the same types. Existing worktrees must run PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false pnpm install --frozen-lockfile to rebuild dependency links after this setting changes. In PowerShell, use $env:PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE='false'; pnpm install --frozen-lockfile. New sessions use it automatically after updating the executor.
Shared plugin packages use content hashes and retain previous packages. Native Codex cache directories use the version from the plugin manifest. Appending a hash to that cache version causes native installation to remove the directory and invalidate the workbench's stored path. The plugin authentication SDK resolves current installation identities from the Executor's capabilities/manifest-v2.json, ignoring any old manifest left after migration.
Continuation retains the task ID but assigns a new subtask ID per turn to refresh MCP context and distinguish messages. Transcript reads and continuation use the native Home that owns the first turn. Codex query processes are isolated by Home. History refresh reads without resuming a session, leaving its writer lock available for the next execution. Continuation retains user and Team identity without credentials. Backend Teams use agents/<executing-user-name>/<namespace>/<Team-name> under the workbench root as their native Home. Pipeline Bots each use a bots/<Bot-ID> subdirectory with independent configuration, sessions, and execution locks. Task-scoped skill calls resolve multiple stages only when exactly one Home holds its execution lock and records that task as active. Direct Wework profiles (team_id = 0) continue to use the application's managed native Home and retain this marker when the manager resumes. Their selected backend Skills deploy into the task workspace's .codex/skills, while Codex owns native plugin Skills. Environment changes may restart the shared Codex process only after all active turns and pending RPCs finish.
Reusing the same named Home no longer requires its backend URL, Team ID, Bot ID, or Shell type to match the previous marker; task bindings and sessions are preserved. Task Skills lookup requires a matching authenticated user ID and recorded task binding, without treating the backend URL as an identity. A shared workbench root therefore does not isolate independent backends with overlapping user and task IDs; that isolation requires a stable backend identity supplied by the authentication protocol.
Before creating its asynchronous runtime or starting Agent child processes, a Unix executor runs the current user's non-interactive login shell to read the login environment without initializing prompts, completions, or plugins used only by interactive terminals. Environment variables needed by Agents should be configured in a startup file read by the login shell. The executor prefers the login shell from the system user database and falls back through $SHELL, zsh, bash, and sh. Environment capture has a fixed timeout. On failure, the executor keeps its parent environment and still appends standard developer locations such as Homebrew and /usr/local. The executor then passes the resulting environment consistently to Codex, Claude Code, plugins, skills, hooks, PTYs, and device commands, so Wework local sidecars, standalone local devices, and Linux cloud or remote devices share the same PATH resolution behavior.
Windows has no login shell to capture, so the executor instead merges the machine and current-user PATH from the registry at startup. This keeps tools that a fresh pwsh can resolve visible to device commands even when the desktop app was launched before a PATH edit. Git diff and hosting CLI status device commands run git, gh, or glab natively without requiring bash or python3, which are not guaranteed on Windows PATH.
Wework uses an isolated Codex Home for local runtime configuration. During first-run initialization, users can copy configuration, plugins, skills, and plugin marketplace data from the native Codex Home. After initialization, Wework writes apps = true under [features] by default so migrated plugin Apps are immediately available. If a user explicitly disables Apps later in Settings, ordinary subsequent startups preserve that choice.
Wework considers the local runtime usable only after the real Codex app-server completes initialize, not merely when the executor stdio transport is connected. After Electron starts the executor, it first applies the current local proxy configuration and then starts and initializes the shared Codex app-server through runtime.codex.ensure_started; the renderer proceeds to the interactive workbench only after that call succeeds. The Codex initialization path must not synchronously wait for plugin marketplace refreshes, Git fetches, update checks, or other external network requests. Those background requests must not delay the initialize response even when the network is unavailable or a proxy never responds. Startup E2E coverage must verify this boundary with the real Codex binary and a blocking network proxy, while also confirming that no Agent model request is sent during initialization.
Electron must keep the main window hidden while it starts and use the separate startup splash window as the only visible loading surface. wework/electron/src/shell/index.html hosts Core DSH startup and failure diagnostics only; it must not imitate the workbench layout, task list, composer, or any other skeleton UI. Electron shows the main window and closes the startup splash only after the Renderer reports the first actionable workbench through renderer.startupReady. Startup failures continue to expose retry and recovery actions through the startup splash. This preserves one visible startup path and prevents an unready main window from covering the animation or flashing placeholder content before the real interface.
The startup splash follows the user's light, dark, or system appearance preference, with matching native window and first-frame backgrounds. Light mode uses a light gray surface, readable status text, and a blue brand mark. The bottom status area blends into the background, and both normal startup and failure recovery controls must support both themes.
wework/electron/scripts/generate-startup-brand.py fits continuous cubic curves from the original alpha silhouette offline and checks a maximum deviation of three source pixels for curved glyphs. The terminal underscore uses horizontal edges and round ends so smoothing cannot bend it. Fitting dependencies are development-only; startup reads the generated data directly. Sixty incoming meteors converge from all sides at density 2.5 and speed 1.0, with bright heads fading before first glyph contact. Trails render at physical canvas resolution instead of enlarging a low-resolution bitmap. Formation and waiting share one vector mark: the sweep starts at its left edge as soon as it forms, without replacing layers or adding another fade-in. Waiting does not run a Canvas drawing loop. Reduced motion shows a static mark; hidden windows and startup failures pause animation. Workbench readiness uses the existing completion and window handoff without waiting for the full animation to play.
Runtime Task and Goal Stateβ
The runtime task running field represents only whether a model turn is currently executing. After a turn completes, fails, or is cancelled, the executor must settle that field to false. Wework uses it to decide whether to render the stop control and running indicator, and whether a new message can be sent directly.
Two live signals jointly determine running: the active-task set owned by the current executor process and a turn explicitly marked inProgress in the Codex thread. The latter covers cases such as automatic Goal continuation, where the local execution wrapper has returned while the provider is still running a later turn. A Codex thread-level active status, persisted task summaries, and Wework reminder state cannot independently infer execution. The task list, transcript, detail pane, system tray, and sleep-inhibition logic must consume the same executor-provided running value. After an executor or Wework restart, the UI must restore running state when thread/read or thread/list still returns an active turn, and settle to idle only when no active turn remains.
Within one executor process, send, guidance, and cancellation operations use one local execution record as the authoritative lifecycle. The Codex threadId and turnId are subordinate context on that execution and remain valid only while its execution ID still matches; they must not live in an independent registry that can keep a task running. When an execution finishes, the executor atomically removes the local execution and its Codex turn context before emitting the terminal response. Provider callbacks such as a concurrent turn/steer response that arrive after completion are ignored and cannot mark the settled task as running again. Active turns returned by thread/read or thread/list can still project task-list state and restore state after restart, but they do not create a second in-process execution lifecycle.
Task summaries also expose Codex threadStatus (notLoaded, idle, systemError, or active) and turnStatus (inProgress, completed, interrupted, or failed) without conflating their lifecycles. The separate continuable field means that the conversation is not archived and can accept another message; it must not be used to infer that a turn is running. Wework renders running feedback only from the explicit running field and real turn state, and does not convert an active thread or message status into streaming.
Refreshing thread metadata must not overwrite a persisted terminal task state. When a Codex thread becomes idle, the executor preserves local done, cancelled, or failed; only a genuinely active turn can move the task back to running. A normally completed conversation that remains available for follow-up therefore has status=done, running=false, continuable=true, threadStatus=idle, and turnStatus=completed.
Goals have an independent lifecycle. An active goal means that its objective can continue in later turns; it does not mean that a model turn is currently executing. Keeping an active goal while a task is idle must not mark the task as running again. A user's next message creates a new turn directly instead of being sent as guidance to an in-progress turn.
If a user creates a Goal while a normal turn is still running, Wework retains that request and starts a new Goal turn with initialGoal after the current turn explicitly settles. An active Goal keeps the task visibly running, but it must not block this queued Goal handoff; ordinary queued messages still wait until the task is genuinely idle. The executor waits for Codex automatic continuation only when the Goal was already active before the turn started. If the Goal is created during a normal turn, that execution must settle first so Wework can start the queued Goal turn. This boundary prevents a deadlock where the frontend waits for an idle task while the executor waits for an automatic continuation that Codex will not create.
To recover a genuinely running Goal across a Wework or executor restart, runtime work journals the active Goal execution in the encrypted turn queue. Recovery is based on that execution record, not inferred from goalStatus=active: only a Goal that was still executing before the restart is rebound. The executor subscribes to events before calling Codex thread/resume and must not create an additional turn/start, fabricate a user message, or inject a continuation prompt. Later turns remain driven by the native Codex Goal protocol. Pausing, clearing, or completing the Goal removes its execution record so a later startup cannot restore it incorrectly.
A recovered Goal turn uses its new native turn identity. It must not reuse the persisted requestβs clientUserMessageId, user-message presentation binding, or generated-user-message marker. The original user message remains bound to its original turn. The same rule applies to native Goal recovery after user action is required.
The executorβs active transcript cache accumulates both item/agentMessage/delta and item/plan/delta by native item ID. History loading can therefore read partial text before item/completed arrives. Stopping execution persists that text as interrupted output so a follow-up or reopening the task cannot discard it. The final item snapshot replaces accumulated text instead of appending it again.
Unphased text items that are still accumulating deltas remain process text, matching the live stream, and adopt their terminal projection only after completion. History loading must not promote it to a final answer early. If Goal recovery fails or is cancelled before a native turn starts, terminal events must also omit the old user-message identity without modifying the original request used for backend reporting.
After one physical Goal turn completes, the executor waits for Codex to create the next turn automatically. If that wait times out, it reconciles against provider-authoritative state through thread/goal/get and thread/read: it rebinds and continues listening when an active turn already exists, attempts native thread/resume once when the Goal remains active but the thread is idle, and exposes needsAttention to Wework instead of waiting silently when continuation still cannot proceed. Wework uses the Goal execution states running, recovering, and needsAttention to distinguish normal execution, restart recovery, and user-action-required recovery. A user recovery action reuses the retained Goal request instead of sending an ordinary chat message.
A timeout waiting for initial model or tool progress does not prove that execution stopped. When the shared Codex app-server has only one active execution, the executor may terminate that process and report a startup timeout failure. If restarting is skipped to protect other active executions, it must retain the original startup request, notification subscription, and cancellation handler until an actual terminal outcome arrives, without reporting failure or marking the Goal needsAttention. Recovery eligibility is checked again at each startup timeout interval. Cancellation must still send a native startup interrupt while the startup response is pending or a Goal has no turn ID. A stopped execution already marked needsAttention must not be revived by a streaming turn left in historical transcripts. Suppressed turn IDs are retained as history so late snapshots cannot replace a newly resumed turn; a new running executor snapshot or an explicit recovery can still start subsequent execution. Task status, Goal recovery indicators, and thinking indicators must reflect the same execution lifecycle.
The Wework frontend manages every task lifecycle through one user-scoped RuntimeTaskLifecycleStore. The Store owns one state machine per task and routes events to it. The state machine is the aggregate root for execution, turn, Goal, and unread state; its reducer is only an internal transition implementation. The React Provider adapts that same Store for subscription and neither stores nor infers execution state. The task list, composer, message thinking feedback, system tray, close guard, and completion reminders all read the same Store snapshot.
Authoritative frontend execution state is memory-only and is never written to a local file or browser storage. The optimistic starting state created when a user sends a message is owned by the same state machine and converges when the executor explicitly reports running=true or running=false. During automatic continuation of an active Goal, the task remains visibly running between turns and after a page reload while either local execution remains active or the provider still reports an inProgress turn. A turn without streaming content may remain idle, so Wework shows no thinking indicator and creates no unread marker. To preserve unread edge detection across an application restart, Wework persists only task keys for unread results and task keys that were last observed running. The latter is not an execution-state source and cannot override the executor's current snapshot.
Before a normal follow-up on a persistent thread calls turn/start, it must use thread/read to confirm that no active turn exists. Ephemeral threads do not support thread/read(includeTurns), so their sends must instead check the executor's local active execution inside a per-task serialized critical section. A new turn must register itself as locally running before a concurrent sender can leave that section. If the provider rejects an overlapping send, Wework immediately restores the task to running state, refreshes the work list, and preserves the user's input in the queue. Once the provider settles to idle, Wework automatically sends that queued item with the same client message ID to avoid loss or duplication. Completion or interruption clears the active turn and restores the UI to idle. Interrupt-and-send creates a new turn only after the previous turn is confirmed interrupted: persistent threads also confirm that the provider turn stopped, while ephemeral threads rely on interruption of the local execution.
Consecutive sends on an ephemeral thread depend on that thread remaining loaded in the shared Codex app-server. After a successful turn, the executor must not send thread/unsubscribe for an ephemeral thread; otherwise a later direct turn/start can target a thread that the app-server has already unloaded. Ephemeral threads also do not support the paginated transcript RPCs, so transcript queries must read the executor's local runtime cache instead of calling thread/turns/list. Persistent threads continue to unsubscribe after each terminal turn and use the provider transcript as their history source.
A task can receive a provider thread ID before its first turn has materialized. When the runtime handle contains only local user-message presentations without turn IDs and has no completed messages, transcript snapshot, lastTurnId, or subtask turn ID, the executor treats it as an unmaterialized conversation: transcript requests return the local presentation, navigation requests return an empty list, and the provider pagination API is not called. A project board preloads transcripts for bound tasks only while its workspace tab is active, and attempts each task address at most once during one mount. A failure is not retried merely because task timestamps or status fields refresh; a later mount may load it again.
Codex guidance is sent to the active turn through the shared app-server. If that turn finishes or changes while guidance is being sent, the executor reports the race as no_active_turn; Wework then sends the same content as a normal follow-up message so user input is preserved without a misleading send failure.
A conversation can switch models and providers between turns. Wework sends the selected model and provider configuration with every continuation, and Codex app-server applies the new modelProvider during thread/resume. For tasks routed through the Wework router, the executor assigns one stable local model-proxy URL per task and atomically updates its upstream configuration at the start of every turn. After the root thread is created, the proxy binds its thread ID and accepts requests only from that thread or its child threads. The upstream and model supplied by the executor for the current turn are the sole routing authority.
OpenAI reasoning and remote-compaction items can contain encrypted_content that is valid only for the provider that produced it. Before continuing, runtime work compares the previous modelSelection in the task summary with the requested selection and passes a switch marker to the executor when the model or model type changed. On the first target request through the Wework router, the proxy removes provider-bound encrypted reasoning or compaction items and previous_response_id, then consumes the marker only after a successful request. This also covers a compaction request that occurs immediately after switching and temporarily omits the <model_switch> marker, preventing stale ciphertext from reaching OpenAI Responses, Chat Completions, or Anthropic Messages upstreams.
After an explicit failure, βswitch model and retryβ starts one new turn. It preserves the original task and portable thread context and sends exactly one request to the newly selected upstream. The executor also writes the turn's modelSelection back to the task summary so the model shown after a refresh matches the model that handled the request. Guidance sent while a turn is running remains part of that turn and does not switch models; the new model applies only to a normal new turn or one created by interrupt-and-send.
The local model proxy uses the Codex Responses protocol as its internal canonical representation and converts bidirectionally among OpenAI Responses, OpenAI Chat Completions, and Anthropic Messages upstream protocols. When the protocol changes, tool-call IDs and tool-result references in history must be normalized at the request boundary to stable IDs containing only letters, numbers, underscores, or dashes, while preserving a one-to-one mapping within that history. Raw provider IDs must not be forwarded directly into another protocol. Tool-call IDs returned by streaming responses follow the same normalization rule so later tool results still reference the original call and item/started and item/completed settle the same Wework tool block.
Upstream Responses APIs pair a function_call with the function_call_output that follows it, so the encoder must keep a turn's message item outside that turn's call/output group: an assistant turn's text and thinking precede its calls, and a user turn's text follows its results. A message item interleaved between a call and its output is rejected even when the client supplied every result, because such a request reads as one whose output is missing.
Before sending a user message, Wework generates a stable clientUserMessageId and renders an optimistic local message. The ID travels unchanged through the runtime create/send request to Codex app-server's turn/start.clientUserMessageId. When the Codex transcript returns the user message, the executor preserves the same clientUserMessageId, which Wework uses to reconcile it with the optimistic message. The Codex provider item ID remains the provider-event identity, but it cannot replace the client user message ID; otherwise transcript pagination or refresh can interpret one send as two messages.
The turn ID returned by a live send may be a provisional ID allocated by the executor before the provider turn exists, while the later transcript contains the canonical Codex turn ID. When Wework merges the live conversation with the transcript, it first reconciles by canonical turn ID. If the IDs differ, it must fall back to the stable clientUserMessageId, merge both representations into one turn, and adopt the transcript's canonical turn ID. A non-null local turn ID must not disable client-message reconciliation; otherwise races such as automatic Goal continuation can render the same user message and subsequent output twice.
When a terminal event races with a transcript refresh, the same provider assistant item can also appear briefly under both a provisional turn and the canonical turn. The provider item ID is the stable cross-turn alias identity in this case: when an assistant item ID belongs to exactly one canonical snapshot turn, Wework must merge every local alias carrying that item into the canonical turn while preserving live tool blocks or other content not yet present in the snapshot. It must not retain both representations merely because their turn IDs differ, and it must not deduplicate across turns by text content because distinct provider item IDs may legitimately contain repeated model output.
The Codex Goal protocol returns createdAt and updatedAt as Unix seconds, while Wework's RuntimeGoal contract uses Unix milliseconds. The executor must normalize these fields at the provider boundary for thread/goal/get, thread/goal/set, and thread/goal/updated before the frontend reconciles snapshots by updatedAt. Without that normalization, an optimistic Goal's millisecond timestamp always appears newer than the canonical completed Goal's second timestamp, leaving a completed Goal visible as active.
Wework explicitly sets historyMode=paginated when creating a Codex thread. To restore a transcript, the executor first reads thread metadata with thread/read(includeTurns=false), then reads turns in descending chronological order through thread/turns/list, and loads each turn's complete items in ascending order through thread/items/list. Pagination cursors are opaque Codex values; neither the executor nor the frontend may parse them or rewrite them as local offsets. Ordinary requests load one page, while search, Supervisor, and other full-history consumers follow nextCursor to the end. The executor rejects repeated cursors, missing turn IDs, and items attributed to a different turn instead of silently returning incomplete or misordered history.
Paginated transcript responses do not synthesize global rangeStart or rangeEnd. Complete navigation reads turn summaries separately and associates loaded user messages and assistant responses by stable turn ID; turn ordinals must not be treated as page-local message indexes. Unloaded turns retain prompt and response previews. When the user requests older history, the frontend returns beforeCursor unchanged and merges the resulting page into the existing conversation. Real Codex desktop E2E coverage must include conversations larger than one page and verify navigation highlighting on the latest page and after cache restoration, historical hover summaries, and opaque-cursor pagination.
Tool state follows app-server lifecycle events: item/started creates a running tool block, while item/completed must settle the matching block to done unless the item explicitly failed. Some standalone tool items, including image view, sleep, and web search, do not carry a status field. The executor normalizes these terminal items to done in both live event mapping and transcript restoration so Wework does not keep showing a running state or advancing timer after the tool completes.
Before compaction, Wework resolves the destination conversation's saved model selection and rebuilds its model configuration with current credentials, model headers, and the executing device's proxy settings, then passes it to the executor through App-IPC. Persisted execution profiles have credentials and headers removed and must not be used directly for model requests. Conversations without a Wework model override retain native Codex configuration.
Manual context compaction succeeds only after a new contextCompaction item is persisted in the Codex thread, not when thread/compact/start merely accepts the request. Before starting compaction the executor registers and binds the local model route from the task model configuration, using the same decision and registration path as a normal turn, so compacting an idle thread, a thread resumed after an executor restart, or a thread whose route was already reclaimed still reaches a model instead of failing inside the Codex app-server with "no local model proxy route is bound to the Codex thread". The executor then records the latest turn and polls the recent transcript. It returns turnId and compactionItemId to Wework only after the latest turn contains the compaction item. Polling stops at a wall-clock budget that also covers the read RPCs, and the App-IPC deadline for runtime.tasks.compact stays above the executor budget. A compaction turn that already ended without producing a compaction item (a rejected turn, for example) fails immediately with its reason instead of reporting a timeout once the budget runs out. While the request is pending, Wework shows one "Compacting context" processing block, then settles that same block to "Context compacted" or an error state.
Compaction event routing preserves the synthetic ${taskId}-context-compact subtask identity so the real Codex turn ID does not split the optimistic processing block into another message. The executor accepts both item/completed and context/compaction notification forms: duplicate notifications for the same item ID are collapsed, while distinct compaction item IDs are emitted independently. Wework reconciles optimistic and runtime blocks by subtask to prevent duplicate indicators. Desktop E2E coverage uses a controlled mock model endpoint to receive and hold open the compaction request sent by Wework, verifies the confirmation, pending, completed, and follow-up stages, and proves that the follow-up model request contains the compaction summary returned by the mock rather than checking only a UI marker; the same checkpoint also rejects one compaction request at the mock endpoint and asserts that the failure surfaces on its own before the App-IPC deadline and without the timeout copy. Executor regression tests cover the Codex transcript persistence completion boundary, and an executor unit test covers binding the model route for an idle thread.
A Codex turn may interleave reasoning, assistant text, and tool calls. The executor must track streaming offsets and completed snapshots for each assistant text segment by provider item ID. A delta and completed event for the same item represent an incremental stream and its snapshot and must be deduplicated. Completed text from a different item must still be forwarded as subsequent text even when it occurs in the same turn; it cannot be discarded merely because an earlier item emitted deltas. Before Wework moves current assistant text into a tool or processing block, it clears that text stream's offset state so the next assistant segment after the tool starts at offset 0 and preserves transcript event order.
Assistant text enters Wework according to the phase on its streaming start event, but a phase carried by item/started is provisional: Codex can start an item as final_answer and complete that same item as commentary before continuing with tools. An explicit phase on the completion event is authoritative. When it reclassifies the active streamed final as process text, the executor clears that active final and emits a process block with the same provider item ID and replacesItemId, so Wework replaces the provisional final in place instead of retaining both copies or ending the turn early. The executor falls back to the tracked streaming phase only when completion has no explicit phase. A completed explicit final or final_answer item wins; if the turn has no explicit final item, the latest completed assistant text becomes the fallback final result.
The standard cloud executor callback path must preserve the same item identity. Body deltas carry block_id (the provider item ID) and a block-local block_offset measured in Unicode code points; the global offset still addresses the body stream. Backend buffering merges only contiguous deltas for the same block, and Redis atomically appends only the unseen suffix at that block offset. A completed block with the same ID updates the stored text in place. A phase change from final_answer to commentary therefore leaves one copy in live messages, refresh recovery, and persisted history, while identical text from different item IDs remains separate.
Reasoning summaries supplied by Codex enter Wework as thinking processing blocks. A streaming summary appears as a single βThinking Β· summaryβ row and reports only the currently active reasoning progress. After the turn completes, fails, or is cancelled, Wework removes the thinking block instead of retaining a summary placeholder or detail in message history. The executor must map both reasoning deltas and item/completed notifications that carry only the complete summary; otherwise a long reasoning phase degrades to a generic waiting state with no visible progress. Internal reasoning that the provider does not include in its summary is not displayed.
Backend Device Chat Task REST Entrypointβ
The web device chat page still sends messages through WebSocket. For external systems or curl-based callers that need to create the same kind of task, Backend exposes a REST entrypoint:
POST /api/device-chat/tasks
This entrypoint writes central TaskResource and Subtask rows, and reuses the same create_chat_task, device resolution, and trigger_ai_response_unified path as the device chat page. The request does not include workspacePath or localTaskId: regular device chat tasks do not have a project workspace concept. If projectId is provided, Backend resolves the target device from the Project config. If projectId is omitted, Backend resolves the target from explicit deviceId, the existing task device, Wework defaults, or the user's default local device.
Creating a task only requires teamId and message; callers may also send deviceId, projectId, model options, and context fields. Continuing a task sends taskId; Backend verifies that the current user can access the task and then reuses the existing task's client_origin. The response returns the central task and message identifiers:
{
"taskId": 2267,
"userSubtaskId": 3332,
"assistantSubtaskId": 3333,
"messageId": 5,
"aiTriggered": true,
"deviceId": "device-de8f474294621dd5acfd1287",
"chatUrl": "/devices/chat?taskId=2267"
}
The OpenAPI schema is generated automatically by FastAPI from DeviceChatTaskRequest and DeviceChatTaskResponse; no static docs/api file needs to be maintained.
Communication Architectureβ
The following diagram shows how local devices communicate with the Wegent system:
Device Typesβ
Device CRDs use spec.deviceType to separate lifecycle ownership and frontend capabilities:
| Type | Lifecycle owner | Connection | Typical entrypoint |
|---|---|---|---|
local | User's local executor | WebSocket | Local installer or manually started executor |
cloud | Wegent cloud device service | WebSocket | Cloud device create, restart, and release flows |
remote | User-managed Docker container or remote host | WebSocket | Remote Docker command generated from Wework connection settings |
remote devices reuse the local executor WebSocket registration, heartbeat, task execution, and command RPC channels, but RemoteDeviceProvider lists them separately and returns remoteConfig. Backend does not persist the WEGENT_AUTH_TOKEN contained in the generated command; the Device CRD stores only non-sensitive metadata such as provider, image, deviceId, deviceName, backendUrl, and createdAt.
After a remote Docker device starts, it sends device:register with device_type=remote, which updates the matching Device CRD. Online state still uses the Redis device-online key, so task routing, slot accounting, and terminal/code-server session RPC use the same protocol as local devices. The frontend does not expose cloud lifecycle actions for remote devices; users stop, restart, or remove the container on the Docker host.
Project device authorization pool and claimingβ
By default, any available device owned by the project owner may claim a Run. Once devices are granted to a project through the existing ResourceMember kind + resource authorization relationship, those grants become the project's device allowlist. No dedicated mapping table is introduced.
Neither automatic-processing rules nor manual assignments require the user to select a device. A human target only changes the assignee. An Agent or collaboration-group target creates a queued Run without a bound device. Claiming then verifies, in order:
- the device belongs to the Run owner;
- the device is allowed by the project device pool;
- if the same Issue previously ran on a device, the new Run keeps that device affinity;
- the device and Agent still have available capacity.
A successful claim atomically writes the canonical device ID, execution environment, lease, and runtime request through the same conditional update so two devices cannot claim the Run. A project with no explicit device grants remains open to the owner's devices; after the first grant, only allowlisted devices are eligible. Presence affects whether a device can claim now, but does not prevent an administrator from pre-authorizing an offline device.
π‘ WebSocket Protocolβ
Event Typesβ
| Event | Direction | Description |
|---|---|---|
device:register | Device β Backend | Device registration |
device:heartbeat | Device β Backend | Heartbeat keepalive |
task:execute | Backend β Device | Task dispatch |
task:progress | Device β Backend | Task progress |
task:complete | Device β Backend | Task completion |
Rust Executor Local Event Coverageβ
The Rust executor Backend channel must remain event-compatible with the legacy Python local device runner. In addition to task execution and heartbeat events, the local device currently registers and handles:
task:cancel,task:close-sessionchat:messagedevice:execute_commanddevice:sync_capabilitiesdevice:start_terminal_session,device:start_code_server_sessionterminal:input,terminal:resize,terminal:closeruntime:rpcdevice:upgradedevice:run_extension
The extension_scope field of device:run_extension accepts task or
global and defaults to task when omitted. Task-scoped extensions run from
the current task's .claude/skills/<extension> directory; global extensions
run from ~/.claude/skills/<extension> for the user running the executor. The
script path must remain inside the selected extension directory, and other
scope values are rejected.
The migration coverage matrix is tracked in
executor/docs/LOCAL_DEVICE_PYTHON_MIGRATION_TESTS.md. When adding a local
device event, add coverage to
executor/tests/local_backend_device_migration_contract.rs first, then update
that migration matrix.
Message Formatβ
// device:register
{
"event": "device:register",
"data": {
"device_id": "uuid-xxx",
"name": "Darwin - MacBook-Pro.local",
"max_slots": 5
}
}
// device:heartbeat
{
"event": "device:heartbeat",
"data": {
"device_id": "uuid-xxx",
"running_task_ids": ["task-1", "task-2"]
}
}
// task:execute
{
"event": "task:execute",
"data": {
"subtask_id": "subtask-xxx",
"prompt": "User message",
"context": {}
}
}
π Heartbeat Mechanismβ
Sequence Diagramβ
Timing Parametersβ
| Parameter | Value | Description |
|---|---|---|
| Heartbeat Interval | 30 seconds | Device sends heartbeat |
| Online TTL | 90 seconds | Redis key expiration |
| Monitor Interval | 60 seconds | Backend checks expired devices |
| Offline Threshold | 3 missed heartbeats | Device marked as offline |
If one device:heartbeat ACK times out, is rejected by Backend, or hits a Socket.IO transport error, the executor quickly retries the next heartbeat after 10 seconds instead of waiting for the full 30-second heartbeat interval. After two consecutive heartbeat failures, the executor proactively disconnects the current socket and enters the reconnect-and-register flow. This tolerates one transient hiccup while still normally letting the device re-register and refresh online state before the 90-second online TTL expires after a short network interruption recovers.
Running Task Trackingβ
Each heartbeat contains currently running task IDs, used for:
- Real-time slot usage tracking
- Orphaned task detection
- Automatic cleanup on disconnection
Global Capability Reportingβ
Local devices also report Claude Code global capability state through heartbeats. A full report includes:
capabilities.revision: local Wegent-managed manifest revisioncapabilities.digest: content digest forskills,plugins, andmcpscapabilities.skills: Skills available under~/.claude/skillscapabilities.plugins: Plugins installed in~/.claude/plugins/installed_plugins.jsoncapabilities.mcps: Wegent-managed global MCP configuration
Plugin reports must include the Skills contained inside each plugin. The executor scans SKILL.md files under each plugin install directory and returns them in plugins[].skills[]:
{
"name": "context7",
"marketplace": "claude-plugins-official",
"version": "1057d02c5307",
"source": "wegent",
"installed_plugin_id": 301,
"skills": [
{
"name": "context7",
"description": "Look up version-specific documentation.",
"path": "skills/context7"
}
]
}
Backend persists the complete capability state only when capabilities.full = true. Later heartbeats with the same digest refresh device liveness without rewriting the full capability lists.
Global Capability Syncβ
Backend can send desired global capability state to an online local device through device:sync_capabilities. The sync payload currently includes:
skills: backend-resolvedInstalledSkill/Skillentries, downloaded by the executor into~/.claude/skillsplugins: backend-resolvedInstalledPluginentries, written by the executor into~/.claude/plugins/installed_plugins.jsonmcps: backend-resolvedInstalledMCPentries, written into the Wegent-managed manifest
In replace mode, the executor only removes capabilities marked as managed in the Wegent manifest and missing from the desired state. Plugins installed directly by the user on the local machine are not removed by a Wegent sync.
Capability package downloads are constrained to the configured Backend origin. The executor resolves relative package paths against connection.backend_url, rejects package URLs from other origins, and only attaches the device bearer token to same-origin Backend requests. Skill download URLs are built with encoded query parameters, and package extraction uses a per-sync staging directory before replacing the managed skill directory.
When a project task runs through the local executor, its task-level CLAUDE_CONFIG_DIR exposes both global skills and plugins directories and inherits non-sensitive plugin settings such as enabledPlugins and extraKnownMarketplaces from the local ~/.claude/settings.json. This lets Claude Code load global Skills and Skills provided by Plugins. Sensitive model and token configuration is still injected through runtime environment variables and is not copied from global settings into the task directory.
Claude Code, Agno, and Codex task shells receive a task identity environment set. WEGENT_TASK_ID identifies the current Task, WEWORK_PARENT_TITLE provides the current task title, AUTH_TOKEN provides the per-turn bearer token for Backend API access, WEGENT_RUNTIME_AUTH_TOKEN provides the bearer token that local Skills use to access Wegent runtime APIs, and WEGENT_SKILL_IDENTITY_TOKEN plus WEGENT_SKILL_USER_NAME identify task-scoped Skill operations. Claude Code and Agno receive the values through their child-process environments. Codex receives them through thread-scoped shell_environment_policy.set.* settings, so task identity never enters the shared app-server process environment and cannot leak across tasks. After Wework connects to cloud, it issues the runtime token through POST /api/users/me/wegent-runtime-token and refreshes it before the returned expires_in; disconnecting from cloud removes WEGENT_RUNTIME_AUTH_TOKEN from the local Codex config. The executor does not inject WEGENT_SUBTASK_ID into these child runtimes.
When project mode calls Claude or Codex model APIs, the executor adds a wecode-project: <project_id> request header in the directly launched runtime context and fills source identity headers: wecode-action: wegent, wecode-source: wegent-local, and wecode-executor: <runtime>, where Claude Code uses claudecode and Codex uses codex. Claude Code local mode first merges existing ANTHROPIC_CUSTOM_HEADERS from the executor startup process environment and the runtime environment, then appends the project identity and writes the resulting header set to both ANTHROPIC_CUSTOM_HEADERS and DEFAULT_HEADERS/default_headers. This keeps the Claude Code child process and downstream model gateways on the same header set. Codex writes the header into provider http_headers for Wegent-managed provider configs, and also injects it for personal Codex config runs when the execution model explicitly names the provider.
Chat Task Device Resolution And Claude Code Launch Contextβ
When a regular chat Task runs through the local executor, Backend resolves the actual dispatch device before creating or continuing the task. Resolution order is:
- The
device_idexplicitly provided by the current request. - The current Project local execution config, such as
config.execution.targetType = localandconfig.execution.deviceId. - The
deviceIdalready stored in the existing Task spec.
The appDeviceId used by frontend App IPC is only the local process identity. Backend maps it to the executor Socket.IO name stored on the Device CRD before dispatching. If the resolved local device is stale or offline and the current user has exactly one online local executor, Backend switches the task to that online device so a stale id does not block local execution. Unknown device ids are not silently rewritten.
Before launching a Claude Code child process, the executor prepares the task context:
- It downloads turn attachments into the task directory. Project workspaces use
.wegent/attachments/<taskId>/<subtaskId>/; non-Project tasks use an attachment subdirectory under the executor task directory. - It restores plugin packages from
~/.claude/plugins/cachewhen they are still enabled inenabledPluginsbut their install directory is missing, and it repairs plugin hook permissions. - It deploys task-selected Skills into
SKILLS_DIR. Regular Project tasks use global~/.claude/skills; standalone local work withproject_id = 0and task Skills uses task-level.claude/skillsso the global directory is not polluted. - If
WEGENT_FILE_EDIT_HOOK_COMMANDis configured, it writesWrite|Edit|MultiEdit|NotebookEditPreToolUseandPostToolUsehooks into Claudesettings.jsonso file-change records can be captured as turn artifacts. When Wework macapp starts the local sidecar, it generates this command by default;WEGENT_FILE_EDIT_HOOK_COMMANDcan override the full command, andWEWORK_FILE_EDIT_LOG_ENDPOINTcan change the default reporting endpoint.
The local executor converts Claude stdout NDJSON into Responses API events as soon as output arrives: visible text becomes response.output_text.delta, reasoning summaries become response.reasoning_summary_text.delta, and the process still sends a final response.completed or error event after exit. Backend and frontend code must not assume that response.created is followed immediately by a terminal event.
For device callbacks, Backend holds the same per-subtask event lock across queue write-back, text storage, frontend broadcasting, and completed-result collection. Database awaits must not allow completion to overtake the final text delta, which would truncate the text blocks in the completed result.
π Task Execution Flowβ
Local collaboration group orchestrationβ
Wework manages local projects, agents, and collaboration groups through the local Executor and persists them in the Executor's local SQLite database. Cloud projects and their members, agents, and collaboration groups remain Backend-owned. The Local and Cloud UI domains select the active resource boundary; they must not merge both domains into one resource list or expose cloud-only actions, such as inviting members, for local resources.
When a local Issue is assigned to a collaboration group, the Executor reads the
leader, members, and coordination rules from the owning project's
collaboration_groups configuration. A group without a leader does not start
automated orchestration. A group with explicit stages creates workflow nodes in
stage order. In manager coordination mode without explicit stages, the Executor
first creates a manager node and lets the leader plan the remaining work from
the Issue context.
The manager runtime receives only task-scoped wework_space tools for reading
the current Issue, listing candidates allowed by the current collaboration
group, and submitting one plan through submit_workflow_plan. The plan is
validated in one transaction:
- the caller belongs to the active collaboration workflow for the current Issue;
- every
client_keyis unique, and every item has a title and assignee; - every assignee is a member of the current collaboration group;
- every agent is active in the current project;
- a workflow can accept only one successful plan submission.
After validation, the Executor expands the plan into persisted workflow nodes and creates or advances child tasks in dependency order. A rejected plan does not leave partially written nodes, keeping the Issue and workflow consistent for diagnostics. Local workflows do not call cloud project-space assignment or outcome-reporting APIs; cloud collaboration continues to use Backend-owned project-space workflows.
Task State Transitionsβ
π Security Mechanismsβ
Authentication Flowβ
Security Featuresβ
| Feature | Description |
|---|---|
| JWT Authentication | WebSocket connections require valid token |
| Token Expiration | 7-day expiry, requires periodic refresh |
| User Isolation | Devices can only execute tasks from their owner |
| Hardware Binding | Device ID generated from hardware identifiers |
Backend-triggered terminal and code-server sessions resolve relative paths under the configured local workspace root. Backend-triggered upgrades must stop running local tasks before restarting the executor: if force_stop_tasks is not set, the upgrade is rejected as busy; if forced cancellation fails for any task, the upgrade is aborted and an error status is emitted instead of proceeding to restart.
Local Executor Connection Configurationβ
On startup, the local executor resolves configuration in this order: environment variables, ~/.wegent-executor/device-config.json, then defaults. If WEGENT_EXECUTOR_HOME is not set, the executor uses ~/.wegent-executor. The executor always starts the HTTP server. Wework sets WEGENT_APP_IPC_DEVICE_ID on its child process, explicitly enabling local App JSONL IPC on the current process stdin/stdout. If connection.backend_url or WEGENT_BACKEND_URL is also set, that same process connects to Backend, using connection.auth_token or WEGENT_AUTH_TOKEN for device authentication. A standalone Local Executor launched only with Backend connection settings does not enable the stdio control plane and continues to use the existing Socket.IO remote-device path. Wework App manages and communicates only with the executor child process it starts directly; it does not discover or attach to an executor started manually outside the App. A full App exit also terminates only the child it owns.
On macOS, development mode uses a Node watcher that builds and starts the real executor once, then rebuilds and restarts it after source changes. Windows development mode continues to use wegent-executor-dev. The watcher must also monitor the Wework process that launched it: on Unix, a parent PID change stops the current executor and exits so the operating system cannot adopt the watcher and let it keep restarting executors after Wework has exited.
EXECUTOR_MODE overrides mode. docker starts only the HTTP server. Other values start the loopback HTTP server and select either the Wework stdio control plane or the standalone Local Executor remote Backend control plane from the explicit identity described above; no local IPC socket is created. WEGENT_BACKEND_URL overrides connection.backend_url, WEGENT_SOCKET_URL overrides connection.socket_url, and WEGENT_AUTH_TOKEN overrides connection.auth_token. An empty Socket URL defaults to the Backend URL. When the origins are split, HTTP APIs use the Backend URL while the executor Socket.IO transport uses the independent Socket URL. Normal standalone startup scripts therefore do not set WEGENT_APP_IPC_DEVICE_ID, preserving the existing remote behavior and transport.
Cloud Device Bootstrap Identity Variablesβ
Cloud devices use a user data startup script to install and run the executor automatically. The startup script injects these identity-related environment variables:
| Variable | Source | Purpose |
|---|---|---|
WEGENT_AUTH_TOKEN | API key generated by the backend for the cloud device | Allows the executor to connect to the backend and register the device |
WEGENT_USER_JWT_TOKEN | Current user's Bearer JWT from the cloud device creation request | Allows scripts or integrations on the cloud device to access backend capabilities as the current user |
WEGENT_USER_NAME | Current login username | Allows scripts or integrations on the cloud device to identify the current user |
WEGENT_AUTH_TOKEN and WEGENT_USER_JWT_TOKEN must not be used interchangeably: the former represents the device authentication identity, while the latter represents the user identity at cloud device creation time.
Cloud Device Bootstrap System Configurationβ
When creating a cloud device, the backend generates the initial login password for the ubuntu user and stores it in the Device CRD spec.cloudConfig.ubuntuInitialPassword field. The user data startup script uses that password with chpasswd to initialize the ubuntu user's password.
The same user data startup script also creates /etc/systemd/system/fstrim.timer.d/override.conf, configures fstrim.timer to run daily, then reloads, restarts, and enables the timer.
User Isolationβ
Each device session is bound to a user:
- Devices can only receive tasks from their registered owner
- Prevents cross-user task execution
- Subtasks validated against user namespace
Data Privacyβ
When using local devices:
- Code stays local: Source code is never uploaded to cloud
- Local execution: All processing happens on user's machine
- Result streaming: Only output text is transmitted
- No persistent storage: Cloud doesn't store local files
π§ Device ID Generationβ
The Executor automatically generates a stable device ID based on the following priority:
- Cached ID: Stored in
~/.wegent-executor/device_id(if exists) - Hardware UUID:
- macOS: System hardware UUID
- Linux:
/etc/machine-id - Windows:
MachineGuidfrom registry
- Fallback: MAC address or random UUID
This ensures devices maintain consistent identity across restarts.
π Concurrency Controlβ
Slot Managementβ
Each device supports up to 5 concurrent tasks:
- Slot usage tracked in real-time via heartbeats
- Device shows "busy" when all slots are occupied
- Tasks queue if busy device is selected
Load Balancingβ
π Related Documentationβ
- Local Device User Guide - User operation guide
- System Architecture - Overall architecture design
- OpenAPI Responses API - API reference
π¬ Get Helpβ
Need help?
- π Check the FAQ
- π Submit a GitHub Issue
- π¬ Join community discussions