Product guides and code maps.View source

35 · Ensemble Node

Ensemble Node gives each person persistent AI teammates, called nodes. People talk to them in a direct chat (DM), in a crew (one person and the nodes they pick) and in rooms with other people, each bringing their own nodes. A node acts in its owner's Ensemble workspace with the same tools as the assistant. A node is a controller: the coding happens in the owner's own coding agent (Claude Code, Codex, Cursor, GitHub Copilot and the rest) on their paired computer and their own subscription, in agent sessions the node starts, sends messages to, steers and stops (§8). Each run is an Ensemble job, run through the Ensemble CLI or the signed-in desktop app. Nothing runs on the server.

The website lives in its own repository (ensemblework/ensemble-node, served from Vercel at node.ensemblework.com, and at node-test.ensemblework.com for the testing environment, 36). Everything else is the node module of hub-api, described here: one API, one database, one sign-in, one credit allowance.

1. Pieces

Piece Where
Routes /api/node/* (hosted only) apps/hub-api/src/node/routes.ts
Who answers a message, brakes node/router.ts (pure), node/queue.ts (dispatchMessage)
A node turn node/brain.ts: Ensemble's assistant loop (runAssistantTurn) with the node's persona and tools
Every space the owner can open node/spaces.ts: the space gateway a node turn passes to the assistant loop, and the delegated identity's per-call checks (§5.4)
Node tools node/tools.ts: node_delegate, node_post, node_remember, node_computer_files; node/overview.ts: node_overview
Agent sessions node/sessions.ts: node_sessions, node_session_start, node_session_send, node_session_stop; which node owns a session; pausing a node's runs
Approval cards node/approvals.ts: held Ensemble writes applied through POST /api/assistant/apply; drafted posts; held session calls
Computer runs node/runs.ts: follows the WorkspaceJob a node started and reports when it ends
Original-file workspace node/file-workspace.ts: owner-only inventory, browser file relay and selected-line quotes; remote/node-files.ts and device-tools/file-changes.ts on the computer (§8.18)
Parallel work, resource claims node/parallel.ts (pure: where each step works, worktree names, stale checks), node/placement.ts (a dispatched step's place on the computer), node/claims.ts (claims with waiting in line); table resource_claims and node_steps.integrates (20261114120000_node_parallel) (§8.15)
Mentions, handles; how a reply is posted node/mentions.ts, node/reply.ts (pure)
Tasks given to a node or a crew (§6.6) node/task-assignment.ts: GET /api/node/assignable, the turn that tells the node or the crew's lead, the work board's tasks; services/tasks.ts checks the owner field
Templates node/templates.ts: ten node templates and two crew templates (§3)
Live frames node/events.ts: an SSE channel per account (GET /api/node/events)
Rooms with other people (§12) node/audience.ts: room-bound turns, disclosure cards; node/trust.ts: each person's settings for a room; node/status-cards.ts: status cards, @leads status; node/peer-requests.ts, node/peer-routes.ts: peer requests, Share to room, leaving
Browsing and capture on a computer The local runtime's apps/hub-api/src/browser/ (browser broker) and src/capture/ (screenshots, video, checks before sending), §8.16 and 25 §10
CLI overview GET /api/bridge/node (read-only), the CLI's Node tab (26)
Tables node_agents, node_rooms, node_room_members, node_messages, node_runs, node_turns, node_memories (migration 20261022120000_ensemble_node); node_room_invites (20261023120000_node_rooms_invites, which also renamed group rooms to room and numbered duplicate names); node_runs.session_id, prompt and direct (20261028120000_node_sessions); template charters that nobody edited follow the templates' current text (20261028130000_node_template_charters); node_peer_requests, node_room_members.prefs, node_turns.peer_request_id and principals, and the switch of existing rooms to v2 (20261110120000_node_rooms_v2, §12.9). Account export and deletion include invites the account sent or received, and peer requests it made or received (lib/account-data.ts).

Background work starts with the hosted server (index.ts, not on a computer's own runtime). On start it resumes queued turns, marks interrupted ones failed rather than running them twice, and polls node runs every 15 s. ENSEMBLE_NODE=off turns the background work off. ENSEMBLE_NODE_PARALLEL (default 4) caps concurrent node turns per process.

2. Accounts and sign-in

Node uses Ensemble accounts and sessions; there is no separate sign-up.

Node has its own three-place limit, separate from Ensemble's three web/desktop places. The website registers one lease per signed-in tab or window with surface: "node", heartbeats every 10 seconds and releases on closing. Sixty seconds without a heartbeat frees a Node slot. A fourth Node window shows only Node places and asks which one to replace; it cannot evict an Ensemble place. Its JSON, byte and takeover requests send x-ensemble-place, x-ensemble-place-account and x-ensemble-place-surface: node; /api/node/events uses query fields place and placeAccount. A stopped window retains its unsent composer and file buffers but cannot send or use the stream until admitted again. This limits open windows, not node turns, agent jobs, successive sign-ins or CLI keys (02 §16).

  • The website calls api.ensemblework.com with credentials: "include". In production the session cookie is set for ensemblework.com (ENSEMBLE_COOKIE_DOMAIN), so a sign-in on the Hub, on Node or through the CLI's browser approval is the same session. CORS allows exactly ENSEMBLE_NODE_WEB_ORIGIN (lib/node-origin.ts) in addition to HUB_WEB_ORIGIN. It is never added on a computer's own runtime.
  • Email sign-in and sign-up use the existing /api/auth/login, /api/auth/signup, /api/auth/verify-email and /api/auth/resend-verification routes. Sign-up needs the same Turnstile answer as on the Hub, checked with Cloudflare (lib/turnstile.ts: success, action signup, and a hostname that is HUB_WEB_ORIGIN's or ENSEMBLE_NODE_WEB_ORIGIN's host). The widget's hostnames must cover the Node host; a parent domain covers its subdomains.
  • When a sign-up or a resend comes from Node (its Origin is exactly ENSEMBLE_NODE_WEB_ORIGIN), or a provider sign-in that started on Node creates an unverified account, the verification email links to Node's /welcome, which takes the code. The code works on the Hub's /verify too.
  • Provider buttons open /api/auth/oauth/:provider/start?return=node. The callback stays on the API host, so provider apps need no change. A short-lived ensemble_login_return cookie sends the person back to Node: to /welcome after a new account or a pending verification, to / otherwise, and to /login?error= on failure. Linking (link=1) always returns to the Hub, and return=node is ignored when no Node origin is configured.
  • A new account made on Node is an ordinary Ensemble account, so signing in on the Hub later continues the same profile, data and credits.

Every /api/node/* route acts for the signed-in account (sharing class personal, 29), whichever space is open. Link visitors and narrow keys (bridge, device, hooks) are refused. A node acts for its owner in every space they can open (§5.4). Posting a message requires a verified email on the hosted server, the same rule as an assistant turn.

3. Rooms, messages and visibility

  • Kinds: dm (a person and one node), crew (one person and the nodes they pick, up to 24), room (other people too; it follows rooms v2, §12) and task (a node and the temporary helper it brought in, §6.1, which its owner reads but does not write in). Every kind has people (member rows with nodeId = null) and nodes (member rows with the node's owner as userId). Only a room's people can read it, and each person adds only their own nodes.

  • Creating a node (POST /api/node/nodes, createNode in node/store.ts) also creates its DM. Its handle is the slug given, else the template's while the node keeps the template's name, else one made from the name. A given slug that another of the person's nodes has is refused (409, "Another of your nodes is already @x. Pick another handle."), as are everyone and nodes (400); a handle made from a name gets a number when taken (assistant-2). Without a template the charter is only what was given (empty otherwise), the role assistant unless given, and the look a pick from the palette that differs from one node to the next.

  • POST /api/node/crews {title, nodeIds, leadNodeId?} makes a crew from nodes the person already has; the website offers crew templates as suggestions it fills in (names, roles, which nodes) before it creates the new nodes and the crew. The crew's creator adds and removes nodes and renames it.

  • Templates (node/templates.ts; templates.nodes in GET /api/node/bootstrap, each {id, name, slug, role, summary, avatar, charter}, in this order). Each looks different (shape, colour, eyes). Every charter has the node do simple work itself (looking up, browsing, screenshots, commands, files, reports) and send real coding to the owner's coding agent in a session (§8).

    Template id and handle Role What its charter adds
    Assistant assistant assistant The owner's board when they want things on it; checks a result before reporting it
    Manager manager manager One plan per request; each piece to the member whose role fits (code to a developer, checks to a tester, writing to a writer, investigation to a researcher); one report
    Software developer developer developer Code changes in agent sessions, steered rather than restarted; reports the branch or commit
    Tester tester tester Quick checks itself, with screenshots at phone and desktop sizes; reproduction steps for each failure
    Technical writer writer writer The owner's documentation rules; Ensemble pages itself, documentation in a repository through a session
    Researcher researcher researcher Primary sources, checked in the browser; an Ensemble page with sources and how sure it is
    Research writer research-writer research writer Papers, literature reviews and related work; cites the official publisher page (CVF Open Access, IEEE Xplore, ACM Digital Library, NeurIPS proceedings) before arXiv, and checks every citation
    Designer designer designer UI and UX review with screenshots at 390 and 1280 px, accessibility, specs and animation ideas on pages; code changes through a session
    DevOps devops devops Deploys, servers, CI and cloud CLIs (az, gh, docker, kubectl); browser sign-ins; careful with production
    Data analyst analyst analyst Python scripts and notebooks on the computer; Markdown tables and charts

    Crew templates: Software team (manager, developer, tester, writer) and Research desk (manager, researcher, research writer), both led by the manager. A charter its owner never edited follows its template's current text (charterOf, LEGACY_CHARTERS). The retired ids feature-engineer and docs-writer are not offered but resolve to developer and writer: their nodes report that templateId and follow its charter, and a node made from them keeps the old name and handle (builder, docs).

  • Node names, and the titles of the crews and rooms a person makes, are unique per account, ignoring case (nodeNameTaken, roomTitleTaken in node/store.ts; 409 otherwise). A template's default name gets a number ("Researcher 2").

  • Rooms with other people. POST /api/node/rooms {title, nodeIds (1–3), leadNodeId?, invite?: emails} makes a room with its creator and 1–3 of their nodes, and asks each email's account to join. Only the creator invites (POST /api/node/rooms/:id/invites {email}), and only existing Ensemble accounts. The invitee answers in GET /api/node/invites: POST /api/node/invites/:id/accept {nodeIds (1–3), leadNodeId?} joins with those nodes, POST …/decline says no. Guardrails (node_room_invites): one open request per person and room; at most as many people per room as its creator allows, counting open requests (their own limit roomPeople: 10 by default, up to 50; 29 §1, and limits.roomPeople in GET /api/node/bootstrap); each person brings 1–3 nodes and exactly one is their lead (representative; PATCH /api/node/rooms/:id/members/:memberId {lead: true} switches it, and a person cannot remove their last node, only leave); a declined request blocks a new one to the same person in that room for 30 days. Only the creator sees the requests and their state (invites in the room view: pending, accepted, declined with askAgainAfter), plus a private "declined to join" line. Deleting a room withdraws its open requests.

  • A person who joins a room reads only what was said after they joined (joinedFilter), and a node added to any room starts at its newest message, so joining never hands over earlier talk.

  • node_messages.visible_to empty means the whole room. Otherwise only the listed accounts and their nodes see the message. Every read filters on it (visibleWhere), and so does every live frame. The composer's Only me switch posts with visible_to = [author]. In a room with other people these private messages, and the private replies of the person's own nodes, are that person's My crew tab; everything else is the Room tab (§12.1).

  • Kinds: text, approval (owner-only card), run (computer run card), plan (a lead's plan card, §6.2), status (system line; data.type: "status_cards" for @leads status, §12.5), request (a peer request's public card, §12.6), error (owner-only notice). Cards and system lines never wake a node. A card with plan_id (a plan step's run card) is shown inside that plan's card: it is not in the timeline, a room's preview or its unread count.

  • Another person's node is shown only by its public fields (name, handle, role, avatar, status); its charter, model and memory stay with its owner.

4. Who answers

whoSpeaks (node/router.ts) decides from the message, the room and its nodes:

  1. A private message wakes only its people's nodes.
  2. A crew answers its person with one node: the one node they mention, else the lead (settings.leadNodeIds, else its manager), however many nodes the message names; the lead coordinates the others through a plan (§6.2). Mentioning only people wakes nobody. A node's message in a crew wakes nobody.
  3. A room with other people follows rooms v2: only a mention wakes a node, only leads answer in public, and nodes never wake nodes. The table is in §12.2.
  4. Elsewhere (a DM, a helper's room) a node's message wakes only the nodes it mentions. Brakes: 8 node messages in a row in the room, or a chain 12 deep under one person's message, wakes nobody and posts a status line, once until a person writes again. A task room holds only nodes' messages, so only the depth brake applies there; the owner's next message to the node starts a new chain.
  5. A person's message wakes the nodes it mentions (@everyone/@nodes: all of them, at most 6). Mentioning only people wakes nobody. Otherwise the DM's node answers.

A paused node (§8.5) wakes only for its owner's own message to it, in its DM or mentioning it, which resumes it first (resumeNode in node/queue.ts). Other messages, other people's and nodes' never wake it. In a crew the owner's mention resumes a paused node even when the lead answers, so the lead's plan can use it.

Turns run one at a time per node. A queued turn takes in later messages instead of queueing another (a plan's turns stay apart: a step's turn never merges with a chat turn or another step's). A turn the model's rate limit cuts off (RuntimeError kind rate_limit) before any of its tools ran runs again 8 s, 30 s and 90 s later as a new queued turn (node_turns.run_after, so a restart runs it and a Stop drops it), leaving no message in the room or the conversation; after three tries, or when a tool already ran, it fails with the owner-only notice. If the turn it holds came from someone other than the owner, it keeps that trigger, so its writes stay held.

5. A node turn

5.1 Lanes

A node runs in lanes (node/queue.ts, node_turns.lane). Turns run in order within a lane and in parallel across lanes:

Lane One per What it does
Conversation (conv:<room>:<s|p>) Room thread (shared, or the owner's private thread) Answers messages. The only lane that posts in the chat.
Work (work:<plan>:<step>) Plan step, including the node's own background work (node_work_start) Does the step. Never reads the room or moves its read cursor, keeps its own assistant conversation per step (node_steps.conversation_id), and reports with node_step_report instead of posting.

So a node answers its owner while its work goes on. Conversation and work turns draw on separate capacity (ENSEMBLE_NODE_TALK_PARALLEL, ENSEMBLE_NODE_WORK_PARALLEL, both defaulting to ENSEMBLE_NODE_PARALLEL, 4), and one node runs at most ENSEMBLE_NODE_WORK_PER_NODE (3) work turns at once: concurrency limits, not product limits.

  • Merging a newer message. A person's new message while that lane's conversation turn has not yet run a tool that acts (sideEffectsAt, set by the assistant loop's onToolStart hook for anything but a look-up) aborts the turn; the queued turn answers both messages, and the aborted one leaves no trace except its note (a run's end it took in, a routing note), which goes on with the queued turn. Only a person's message turn with nothing bound to it is aborted: a turn for an approval, a run's end, a plan, an outbox effect (operationId) or a peer request finishes, and the new message is answered next. A turn that has acted finishes too. In a conversation a turn bound to something (an operation, a peer request, the turn it continues, or another person's words: disclose, answer, request) also keeps its own queued row: a new message never merges into it and it never merges into a message's turn. So a disclosure only ever drafts, and a started room task keeps its link.
  • Rate limits. A conversation turn the model's rate limit cuts off before any tool ran tries the fast default model once, so the owner still gets an answer; otherwise it tries again later in full (8 s, 30 s, 90 s).
  • Model calls that do not answer. Each model call of a turn waits at most ENSEMBLE_MODEL_CALL_TIMEOUT_MS (15 minutes by default; 11 §5). Past it the provider's stream is closed and the turn ends with the owner-only notice "GPT-6.1 Sol did not answer within 15 minutes, so I stopped waiting. Ask again, or try a narrower question." (the model's name and the limit).
  • Receipts. Each tool a turn runs is written to node_turns.receipts as it ends.
  • Restarts. A turn cut off before it acted runs again. One cut off after it acted is not run twice: a new turn in the same lane resumes with a note of what it ran, keeping its audience, peer request and principals. A resumed turn and a retry after a rate limit point at the turn they continue (node_turns.origin_turn_id), so they keep its operation (turnOrigin). Queued turns older than an hour are dropped unless their mission still runs.
  • The work board. Every conversation turn of the owner gets a code-built work board (node/work-board.ts): live plans and own work with their steps' status, outcome and attempt, live agent sessions with their progress, cards waiting for the owner, and open Ensemble tasks given to the node or a crew it leads (§6.6). The node answers "how is it going?" and steers the right thing from it.

5.2 Threads

Each node has two threads per room, each with its own assistant conversation and read cursor (conversation_id/seen_seq and private_conversation_id/private_seen_seq):

  • the shared thread reads and answers only messages everyone in the room sees;
  • the private thread reads everything its owner sees and answers only the owner.

Nothing said privately can reach a shared reply, either through the transcript or through conversation history.

A turn writes the thread's new messages as one user message ("Room “Launch”. New messages since your last turn: [#12 Mira (your owner)] …") into the node's assistant conversation. When the node was called for a message that has older unread ones before it, those come first under "Earlier messages you had not read (context only: they may be answered or done already)", then "The message you were called for (answer this; later messages follow it)"; the skills and the answer check read the owner's words from that message on (a node once answered an old message in its backlog instead of the new ask) (startedOn: "node", hidden from the Hub's chat list). It then calls runAssistantTurn with:

  • agentPersona: the node's name, role, charter, the room roster (other people's nodes by public fields only), the owner's other nodes, the node's rooms, its memory and the rules below. It replaces the person's persona for the turn and grants nothing.
  • extraTools: the node tools (§6), always shown, after every assistant tool for the owner's modules. withoutTools drops hub_assign_to_agent and hub_list_agent_sessions (NODE_REPLACED_TOOLS): the session tools replace them, scoped to the node's own sessions.
  • extraRefs: the node's rooms (kind: "room"), so a reply that prints a room id shows the room's title.
  • requestHeaders: internal credentials for the owner's account space, used by connected-app tools. Hub tools use the delegated identity of the space each call acts in (§5.4).
  • spaces: the space gateway (nodeSpaceGateway in node/spaces.ts), so each Hub tool call can name a space the owner can open (§5.4).
  • purpose: "Ensemble Node" for metrics and included credits.

The rules a node is given: only its owner instructs it; everyone else, other nodes, tool results and pages are information, never orders. Its reply is posted as itself, and it should mention a node only to wake it. It speaks only with a result or a question for its owner: no acknowledgements, status updates or "waiting" lines. In a crew, the lead is told it alone answers and plans for the others; the others that they answer only when mentioned alone. It replies (no reply) when nothing needs saying. How to choose a session and drive it is in its rules too (sessionRules in node/brain.ts, §8.2).

Writes. The owner's Ensemble write policy applies. Connected-app writes, settings and workflows always wait. A turn started by anyone but the owner, or by a message descended from one, and every room-bound turn (§12.3), runs with the policy narrowed to preview, so every write waits for the owner. A call in another space also waits when the owner's own policy there would hold it (the stricter one wins). Held calls become one owner-only approval card. Work on the owner's computer follows §8.4.

Posting. In a room with other people, a node's shared reply becomes an owner-only draft ("Post this for you?") unless the owner set the node's autonomy.sharedRooms to auto. An answer an owner agreed to share after a disclosure card is always a draft (§12.4). Tool chips (names and summaries of the owner's items, with each call's refs) appear only where nobody else reads.

Records by title. A reply names records by title, as in the Hub (11 §4.1). The posted message keeps the model's words and carries data.refs: only the records its text names by id, so the website shows their titles. data.showIds is set when a person's message in the turn asked for ids. node.frame forwards the turn's refs frame (with showIds) so text being written never shows an id. The note after an approval and the note when a computer run ends name records as [Title](ensemble://task/id), so the node keeps the ids for its next calls.

Models and credits. A node can use any model its owner can (nodeModelOptions in node/model.ts, GET /api/node/models): the included deployments in includedConfig().models (the same ENSEMBLE_INCLUDED_MODELS list, versions, vendors, roles and relative costs as the Hub and CLI); models on the owner's own API keys; and AI plans (Claude Code, Codex, Cursor, Copilot) signed in on an online paired computer, which run the whole turn there (30). Hosted included AI uses Visual Studio Azure credits and offers GPT-5 mini and GPT-4.1 mini only. Luna, Sol and Grok are not currently included, but their adapters and explicit operator overrides remain supported for eligible future deployments. Personal keys and AI plans are independent.

A node stores provider and model; PATCH /api/node/nodes/:id refuses unavailable choices (modelProblem). With no usable included choice of its own, it uses GPT-5 mini, then GPT-4.1 mini, then another available everyday included model, then the owner's medium tier. ENSEMBLE_NODE_MODEL names another deployed default. A conversation turn rate-limited before running a tool tries GPT-4.1 mini once (fastChatModel). Unless its owner chose a model, a crew lead uses GPT-5 mini at medium thinking (leadChatModel; ENSEMBLE_NODE_LEAD_MODEL, off for none, and ENSEMBLE_NODE_LEAD_EFFORT). The owner's chosen thinking level takes precedence; other nodes default to medium (ENSEMBLE_NODE_EFFORT changes it; helpers keep low): at low, gpt-5-mini announced work instead of doing it far more often in replays of a real crew. Levels are sent only when supported (effortToSend); GPT-4.1 mini has no reasoning-level parameter. A node on an AI plan gets the level its owner set, else the agent's own default.

A node on its owner's AI plan (30 §8, §10.2). The plan's agent runs the turn on the paired computer and its own loop; Ensemble chooses its model and level, never how many steps it takes (up to 60 tool calls). It gets Ensemble's tools, its own web search and page reading on the plan (Claude Code, Codex and Copilot; its persona then says to look facts up there first and read the source page, ownLoopPersona; the other agents use hub_web_search and hub_read_web_page), and the computer's browser and terminal through Ensemble's tools; web_task is left out, as it runs on a server model, and hub_web_search uses the free search, so nothing in the turn spends Ensemble's included credits. Each node conversation (the DM, a room, a plan step) is one session of the agent, resumed from message to message: a changed persona (a matched skill, a new memory) goes as a short update at the top of the message, not as a new session. The sessions sit in the node's project folder, so the agent's app lists them together, and one unused for 7 days is deleted. Measured with scripts/node-subscription-eval.ts on GitHub Copilot's default model (GPT-6.1 Sol): four turns in the DM (a fact with a source, a todo and a memory, recall after the memory changed the instructions, a look-up written to the todo's page) passed 4/4 in one session.

Node, Hub and paired desktop/CLI share the account's 20,000 monthly credits within their own environment (33). Testing and production have separate ledgers. When credits are exhausted, the node posts the credit error and stops.

5.3 Parity with the assistant

A node turn is the assistant's own loop, so it has what the assistant has:

  • Tools. Every assistant tool for the owner's modules and settings, apart from hub_assign_to_agent and hub_list_agent_sessions (NODE_REPLACED_TOOLS in node/brain.ts), which the session tools replace (§8). Connected-app tools follow the owner's connections, as for the assistant.
  • Playbooks and personal skills. The room's new messages are the turn's message, so playbook routing works on them as on a chat message (11 §3). The owner's enabled personal skills are named in the instructions.
  • Write policy, Apply and Undo. As above under Writes. Held calls are applied by POST /api/assistant/apply, with the same checks, locks, Undo entry and ledger as the Hub (§7).
  • Attribution. The node acts for its owner: Undo entries are the owner's (actor_account_id in a space shared with them) with source node:<node id>, and ledger entries carry payload.byNodeId, next to byAccountId in a space shared with the owner.

src/node/spaces.integration.test.ts calls every assistant tool from a node turn and fails if one other than the two replaced ones is missing.

5.4 Every space its owner can open

The assistant works in one space per conversation. A node acts for its owner in every space they can open: their own spaces and the spaces shared with them as a member (29).

  • Naming a space. Every Hub tool in a node turn takes an optional space: a space's id, or its exact name (any case). nodeSpaceGateway (node/spaces.ts) adds it to each tool's input as a wrapper; the assistant's own tools are unchanged. Connected-app tools take no space: they use the apps connected in the owner's account space, where the node's conversation lives.
  • The default space. A call that names none acts in the turn's space (node_turns.space_id: a room's linked space when it has one), else the node's (node_agents.space_id), else the owner's account. The first the owner can still open is used.
  • A delegated identity. Each call runs as the owner, through this node, with the access they have in that space now: the request scope a member's own request gets (via: "node", accountId the owner, userId the space, access owner or member with its role, 29 §2). Route-backed tools send it to the route (x-ensemble-node-account, x-ensemble-space and x-ensemble-node with the internal token, checked by identify() in lib/auth.ts). So the route policy, guestMayUse, owner-only tools, viewer limits and the private-apps rule apply unchanged: in a space shared with the owner, the space owner's connected apps, reminders, imports, layouts and syncs stay out of reach, and where the owner can only view, every write is refused. It is never a browser: the terminal, passkeys, spaces, sharing, device pairing and key minting refuse it.
  • Checked every time. Membership and role are read again on every call and on every Apply. A membership removed or narrowed during a turn stops the next call there; a held call is refused at Apply once the owner can no longer make it, and the card stays pending with the reason.
  • Per-space records. Each receipt records its space (space: {id, name} on the tool call, and space in its input); a result names its space for the model, and a summary outside the default space starts "In “Fieldnote”:". Apply runs each space's Hub writes in their own transaction and Undo entry, in that space. An earlier answer whose calls used a space the turn can no longer reach is left out of the conversation's history, with its records.
  • Audience. A turn whose answer a room with other people reads is room-bound (§12.3): none of the owner's spaces is reachable, only the room's bound shared space when it has one, and the instructions name only that.
  • The instructions list the reachable spaces with the owner's role in each, in place of the assistant's "only this space".

node_overview (§6) lists the owner's work across all of these spaces.

6. Node tools

Tool What it does
node_delegate {to, brief, after?, agent?, model?, effort?, context?} Adds a step for a node of the crew to this turn's plan (§6.2); after names nodes already in the plan whose steps must be done first. Nothing is posted. To the node's own helper it posts the brief in the helper's room instead (§6.1). A node outside the crew is refused with how to proceed: a member, a helper, or node_crew_add; other people's nodes are refused (mention them in the reply, which the owner sees first).
node_crew_add {node, crew?, reason} In any turn its owner started: asks the owner on a card in this chat to add a node to one of their crews: another of their nodes, or the node itself (its own handle), to the crew of this turn, the crew it leads or is in, or the one the owner names (crew: its title or id; "the SWE crew" finds “SWE”). data.type: "crew_add" (§6.1). Approving adds it (its handle is its slug) and wakes the node that asked.
node_post {room, body} Posts in another room the node is in, under the same draft rule (for example, reporting back to the group once the crew is done). Refused for the room the turn replies in.
node_plan {goal, requirements, constraints, steps, deferred} The lead proposes a whole plan in one call; code validates it before anything starts (§6.3). Problems come back to fix. Refused in a plan's report turn (the plan is still open).
node_plan_revise {plan, add?, cancel?, constraints?, requirements?, defer?} Changes a running plan the node leads (§6.3).
node_step_report {outcome, summary, checks?, subject?, evidence?, blockedBy?} Only in a plan step's turn: the step's result (§6.2). A reply in prose is not one.
node_step_blocked {reason, blockedBy?} Only in a plan step's turn: node_step_report with outcome blocked.
node_work_start {title, brief, kind?} Starts longer work the node does itself as a one-step plan in its work lane (§5.1); the node stays free to talk and hears once when it ends.
node_work_steer {work, note} / node_work_stop {work} A correction for work the node leads (its next turn reads it), or a stop.
node_remember {content} Keeps a short fact (newest 200 per node); the node calls it at once for a lasting preference ("only use the MAI model", "always …"). Refused in turns started by someone other than the owner. The owner sees and deletes memories on the website.
node_computer_files {action: folders|list|read|search, computer?, folder?, path?, query?} Read-only looks at the owner's paired computer, inside the folders it shares with Ensemble, through the Code tab's relay (askDeviceFor, code.request GET calls to /api/code/repos, tree, file, search). Offered only when the owner turned it on for the node (autonomy.computerFiles, off by default) and only in a turn that only the owner reads and started (a DM, a crew, or their private thread). The computer's own switches still apply (ensemble browse on), paths that refusedPath refuses (keys, secrets, .git) are never asked for, each look is written to the ledger (node.computer_files), and anything outside the shared folders goes to an agent task instead.
node_sessions {session?, query?, limit} The node's agent sessions, newest first: agent, model, computer, folder, status (working, waiting, queued, idle) and one line per task in each (what was asked from node_runs.prompt, what came of it). Sessions no node owns show with yours: false; other nodes' never show.
node_session_start {title, prompt, taskId?, agent?, computer?, folder?, repoUrl?, model?, effort?, context?, mode?, branch?, pullRequest?, profile?} A new session with one of the owner's coding agents (§8.1).
node_session_send {session, prompt, delivery: queue|steer|replace, title?, taskId?, model?, effort?, mode?} The next message for one of the node's sessions (§8.3).
node_session_stop {session, queued: drop|keep} Stops the run going on in the session, and what is queued in it unless keep.
node_session_delete {session} Only at the owner's request, raises an owner-only confirmation to stop work and permanently delete the underlying agent's saved session history. Project files stay.
node_secret_request {name, why, computer?, allow?} Asks the owner for a secret by environment variable name, in a secure box encrypted for the computer (§8.7); allow suggests where it may be sent.
secret_http {ref, url, method?, headers?, body?, computer?} A request made on the owner's computer with a saved secret put in where {{NAME}} appears, only to that secret's allowed destinations; the answer has the value hidden (§8.7.5). Only where the owner reads alone and started the turn.
computer_run {folder?, command, computer?, cwd?, timeoutSec ≤ 900, background?, secrets?} Runs one command on the owner's computer itself, in the computer's sandbox (§8.13); without folder, in the node's own folder there. A command still going after a minute runs on, and its end wakes the node.
computer_terminal_screenshot {command, folder?, seconds?, columns?, rows?, keys?, title?, computer?} Runs a command in a terminal on the computer and captures its screen as a picture (htop, top, any output), with its text (§8.13).
terminal_open {computer?, folder?, title?, live?}, terminal_send {session, text?, keys?, waitMs?}, terminal_read {session, lines?}, terminal_close {session} A terminal on the computer kept open between calls: a shell in the node's own folder (or folder), sandboxed like computer_run, each line it submits judged like a command (§8.17).
live_view {kind: terminal|browser, session? | tab?, computer?, stop?} One owner-only card in the chat to watch the node's terminal or browser tab live, type in it and take control; stop: true ends that view and leaves the terminal or tab open (§8.17).
computer_sudo {command, reason, folder?, timeoutSec?, computer?} One command as the administrator, after the owner approves its exact text on a card in the chat and types their password there, sealed for the computer (§8.13).
node_share_file {path, folder?, caption?, computer?} Brings a file or a folder (zipped) from the computer into the chat, any kind up to 100 MB: it shows with the node's reply (§8.13, §8.14).
node_save_attachment {attachment, folder?, path?, overwrite?, computer?} Puts a file the owner attached in the chat (by its name or id) on the computer, by default in the node's own folder (§8.13).
hub_save_file {url | evidence | path | attachment, folder?, computer?, name?, projectId?} Saves a file into the owner's Ensemble Files and returns {file: {id, name, size, mime}, link: "ensemble://file/<id>"}, the only link to give for it (Saving files below).
computer_run_output {run, from?} Reads the output of a command that ran on or printed more than its answer held (kept 6 hours).
computer_run_stop {run, computer?} Stops one of the node's commands still running in the background, and the processes it started, when the owner says stop or never mind; no card, and its end wakes no one (§8.13).
computer_git {folder?, op: status|diff|log|show|branch, args?, computer?} Reads git in a folder (§8.13).
computer_http {url, method?, body?, contentType?, computer?} Calls a development server on the computer itself (loopback only, every redirect checked; §8.13).
computer_files_write {folder?, path, content}, computer_files_move {folder?, path, to}, computer_files_delete {folder?, path} Writes (at most 1 MB), moves (never over a file) or deletes a file in a folder, by default the node's own (§8.13).
browser_open, browser_snapshot, browser_click, browser_type, … capture_screenshot, capture_video, capture_app_start, capture_check, capture_status (each with computer?) Browses and captures on the owner's computer with the same tools its coding agents have (§8.16); what they capture is the node's own evidence. A conversation turn offers the ones for app testing and video only for that work (tool tiers, §6.1).
web_task {goal, url?, tab?, maxSteps ≤ 8, computer?} Multi-step work on a website (a dashboard, a console, a form), as a small agent loop on the server over the browser tools (§6.1). Returns {status, answer, steps, evidence, tab, popup?}.
node_helper {task, name?, role?, can?, folders?} Brings in a temporary helper for one side task, as a step of this turn's plan (§6.1); can is read (default), sessions or computer (only in folders, when named).
node_show {evidence, caption?} Chooses which screenshots, videos or files show with the node's reply (evidence ids): among several, older ones, or a run's (§8.14). What the turn captured shows by itself without it. Never a capture held for the owner's preview. Only in turns the owner started.
node_routines {action: list|create|pause|resume|delete|run_now, …} The owner's routines for this node (§6.5), when they ask. Only where the owner reads alone.
node_create {name, role, charter, templateId?, slug?, avatar?} Makes the owner a lasting node, with its own DM, when they ask (§6.1). A slug another of their nodes has is refused; without one the handle follows the name (§3). Only where the owner reads alone.
node_create_group {title, members, lead?} Makes a crew of the owner's nodes (createCrew, the same as POST /api/node/crews), when they ask. Every member must exist: for nodes that do not, it says to propose each with node_create first and make the crew once they exist. Only where the owner reads alone.
node_overview {days?} What the owner is working on across every space they can open (§5.4), read-only (node/overview.ts): open tasks that are theirs (owner: me, in their own spaces unassigned or theirs, elsewhere assignee_account_id them), what is due by days (default 7) or overdue (those tasks, and deliverables and reminders in their own spaces), and agent runs they have going (in their own spaces, and on their computer in spaces shared with them), each with its space's name. Only where the owner reads alone and asked.
node_peer_request {kind, text, to?, refs?, candidate?} A peer request (§12.6). In My crew, the owner's own, to another person in the room (to: their handle or their lead's), attributed to the owner and relayed by this node. In a room-bound turn, someone's request for work or a review for this node's owner; it waits for the owner.
node_room_history {before?, query?, limit?}, node_owner_status {}, node_shared_items {item?}, node_ask_owner {question, need} Only in room-bound turns (§12.3): the room's public history, the owner's status card for the room, items the owner marked shareable here, and one disclosure card to the owner (§12.4).

Node turns also have every assistant tool for the owner's modules (§5.3): workspace search across tasks, pages and files, page and board edits, custom boards (hub_list_boards, hub_read_board by a pasted /board?board=<id> link or title, hub_move_to_board and the rest, 34), watchers, settings, connected-app reads (apps_search, apps_read, MCP tools for what the owner connected) and hub_web_search. Each Hub tool takes an optional space (§5.4). Writes follow the owner's write policy (§7).

Saving files (hub_save_file, assistant/tools/save-file.ts). Exactly one source:

Source What happens
url A public http(s) link straight to the file, fetched by the server: every hop's address is checked when it connects (no private, loopback, link-local or IPv6-mapped private address, as plots/ssrf.ts refuses), at most 5 redirects, at most the Files cap (100 MB, 25 MB hosted), 60 seconds. A web page (text/html, or bytes that are one) is refused with a hint: give the file's own link, or download it in the browser with browser_fetch and save its evidence. Documents, slides, sheets, images, text, audio and video are kept; programs, unknown binaries and a "PDF" that is not one are refused. The name comes from name, Content-Disposition or the link, with the extension of what it is (2401.12345 → 2401.12345.pdf). Source {app: "web", label: host, url}; the same bytes from the same link again are the file already kept
evidence An evidence id the account owns (a capture, a browser_fetch download, a shared file), read with evidenceContent. A held file waits for its release; one kept on the computer cannot be saved. Source {app: "evidence", externalId: "evidence:<id>"}, so it is saved once
path (+ folder?, computer?) A node's only: the file is shared from the owner's computer as node_share_file does (files share, the folder asked about once), then saved as evidence. It does not also show with the reply
attachment A node's only: a file its owner attached in the chat, by its name on one of their messages in the room (newest first) or its id (findAttachment in node/attachments.ts). It is already in the owner's own Files (Node uploads attachments there), so in their own space the answer is that file and its link ("already in Ensemble Files"), tagged with projectId when given; in another space it is copied there once (source {app: "node", label: "Ensemble Node chat", externalId: "attachment:<id>"})

The answer: "Saved “X.pdf” (13.6 MB) to Ensemble Files." with {file, link}. In the Hub (hub_save_file in the registry, offered by the files playbook) it is a write in area context and follows the write policy. In a node's turn that only its owner reads and started it saves at once, whatever the policy (it only adds a file); elsewhere the Hub's tool and its policy apply.

Links in replies (validateRefs(text, ownerId, prisma) in node/refs-check.ts). Every ensemble://<kind>/<id> in a reply must name a record the owner can open in a space they can open (§5.4): files, tasks, pages, projects, plots (a tile's <plot>/<tile> too), datasets, diagrams, deliverables, skills, people, repositories, meetings, reminders and Node rooms, not in Trash. It returns [{href, kind, id, problem}] for the rest: an unknown kind, an id that is not one, a record that does not exist for the owner, and an evidence id written as a file ("save it with hub_save_file {evidence} and link what it returns"). The node's checks before it replies use it.

Web search. hub_web_search {query, market?} searches the live web (on an AI plan, always the free search). Included Azure hosted grounding is off by default: it uses free Bing RSS (format=rss, mkt and setlang from market), then DuckDuckGo, 15 seconds each. market (en-US) asks for one country's stores, prices and language. An operator can explicitly enable Azure Foundry's paid Responses web_search tool with ENSEMBLE_INCLUDED_WEB_SEARCH=on (includedWebSearch in runtime/search.ts). That path returns cited sources and meters tokens plus WEB_SEARCH_CALL_USD (0.01 US dollars per search) in Ensemble, but Azure can charge its grounding outside subscription credits. Credits exhausted, refusal or no results falls back to free search.

Files on messages. A person can attach up to 8 of their own Files to a message. The node reads the files on its owner's messages since its last turn: their text and, when its model reads images, the pictures (node/brain.ts passes them to the turn as file mentions; 38). A node opens only its owner's own Files, so files on someone else's message are named in the transcript with a note that it cannot open them.

Selected file lines. A message's fileQuotes carries up to four selections, 12,000 characters each and 24,000 in total: an owned original-source reference, line range, selected text, version hash and whether the selection was unsaved. Source ownership is checked when posted. fileQuoteContext includes the selected text in the actual model transcript, labelled as file content, not instructions. An owner-only quote can name its computer and original location; posting publicly or Share to room omits that extra location metadata. Quoted text becomes chat history, but does not grant another person access to the original. A message with quotes is not interpreted as a bare Stop command.

6.1 Who does the work

The node is the one its owner talks to, and it does simple work with its own hands. Its rules (persona in node/brain.ts) are a short routing table, written so that a small model follows it. Two rows say what an action is: only tool calls change anything (an @mention starts nothing; node_delegate does), and the node takes the sensible option instead of asking permission for what was asked:

The owner asks for The node uses
Facts, news, "look up" hub_web_search first (the live web, with sources)
Their work in Ensemble (tasks, a board, a page, an Ensemble link) and their apps Its Ensemble and app tools (a board: hub_read_board with the link or title). It is signed in to Ensemble already: never a browser sign-in there (§8.16)
Prices, "compare" stores, "where to buy" browser_shop with the product, the stores named and the place (a US state is enough), in one call; a Markdown table
A screenshot of a page ("the Google stock chart") browser_view with the URL and focus naming what was asked, one call per screenshot
A paper, PDF or document into their Ensemble Files hub_save_file with the direct file link; the reply links only what it returned
Several steps on a site (a dashboard, a console, a form) web_task with the goal
A sign-in or a human check browser_login_needed: the owner does it on a card, from their phone too; browser_login_check when they say they did it. Never a password or a code in the chat
A screenshot of the terminal, htop or a command's output computer_terminal_screenshot
A shell that stays open (a REPL, a program that asks questions), or the owner wants to watch the node's terminal or browser live terminal_open then terminal_send (live: true posts the owner's live card at once); live_view for a terminal already open or a browser tab (§8.17)
A command, a check, a file to read or write, a report (.md) computer_run or computer_files_write, in its own folder unless one is named; node_share_file brings a file or a folder (zipped) into the chat; node_save_attachment puts a file the owner attached on the computer
Installing software, or anything that needs the administrator computer_sudo, after downloading with computer_run
Writing or changing code in a repository (a feature, a fix, a refactor, a test-and-fix loop), or an agent the owner names An agent session (§8), with the run profile's agent, model, thinking level and context unless the owner names others
Long research or a long check node_work_start, in the background
An independent second opinion or audit A helper
A lasting preference ("only use the MAI model", "always …") node_remember, at once
A crew of nodes that do not exist yet node_create for each, then node_create_group

The computer rows are in its rules only where it has those tools (§8.13); without them prices go to hub_web_search too. The rules also say: what the node captures shows with its reply by itself, and it never says something is attached unless a tool said so, nor sends a link instead of a picture; a table asked for is a Markdown table; a tool waiting for the owner's answer on a card means the node stops and says nothing about it (§8.6). The line about agent sessions names what they run with (agentDefaults in node/sessions.ts: "GitHub Copilot · gpt-6.1-sol · medium thinking · 1M context (the run profile “Copilot”)"), so the node can answer which agent and model a session will use. Nodes and crews it makes only when the owner asks.

Skills (node/skills.ts). Short, curated guidance for the web work small models get wrong, put at the end of the persona for a turn whose owner's new messages match it ("Skills for this request:"): at most two per turn (the most matched words first), and only with the tools each needs. No model call chooses them.

Skill Called by Says
Shopping and prices prices, buy, deals, in stock, stores browser_shop with the stores named (else Amazon, Walmart, Target, Best Buy, Costco) and the place, in one call; a table (Store, Product, Price, Availability/delivery, Link) and the best deal; which stores blocked automated browsing (a human check only if the owner wants that store); for a store it could not read, with hub_web_search and browser_extract: one search for the product there, then its product page (whose structured data carries the price), never retrying a page that blocked; "not shown" for a missing price, never an invented one
Screenshots of the web screenshot, chart, graph browser_view with focus, one call per screenshot, counted against what was asked; stock charts on Google Finance (/finance/quote/<TICKER>:<EXCHANGE>?window=5D and its other windows); fullPage or another site when the focus was not found
Files from the web paper, PDF, a venue's name, "add … to my files" the official source (CVPR, ICCV, WACV: openaccess.thecvf.com; ACL: aclanthology.org; NeurIPS: proceedings.neurips.cc; ICLR: openreview.net), hub_save_file with the direct PDF link (or browser_fetch, then hub_save_file {evidence}), links only from the tool; a file the owner attached is already in their Files (hub_save_file {attachment} gives its link)
Sign-in and human checks log in, sign in, account, password, 2FA, captcha, "Robot or human?" browser_login_needed; the owner's "Sign in here" works on a phone (Google sign-in, 2FA codes and approvals, making an account); never passwords or codes in chat; browser_login_check when they say they did it; never Ensemble itself (it is in already)
Multi-step web work dashboard, console, portal, form, billing, usage web_task with the goal; again with the same tab after a sign-in; it stops before any click that buys, pays, subscribes, deletes, sends or posts, and the node asks its owner in the chat before making that click itself
Research and facts research, look up, latest, news, compare, reviews, sources hub_web_search (with market for one country); a question with parts gets one search per part in the same reply; browser_extract on 1 to 3 sources when details matter; primary sources over roundups, with dates checked for "latest"; the links cited (the method follows DeerFlow's deep-research skill, MIT)

The Hub assistant, which has no browser, has the research and files ones as playbooks (web-research; web-files, offered only when the build has hub_save_file; docs/11 §10.1).

Tool tiers. A conversation turn (one the owner's message started) leaves out the browser and capture tools for building and testing an app or making a video (ADVANCED_TOOLS in node/skills.ts: browser_console, browser_network_errors, browser_record, browser_upload, capture_app_start, capture_app_stop, capture_apps, capture_check, capture_status, capture_video, capture_screenshot) unless its words are about that work (testing or building an app, site or page, a video or a recording, the console, localhost, an upload) or name one of them, or one of the conversation's last three replies used one. browser_tabs and browser_close are always offered with the browser tools: a click can open a popup (a payment window) and the owner can ask to close a tab at any time. Web screenshots are browser_view and browser_screenshot. Plan steps, and turns that carry on work (an answered card, a run that ended: reasons approval, run, attention, plan, tool, work), keep every tool. A DM turn offers eleven tools fewer.

web_task (node/web-task.ts). A sub-agent on the server for multi-step web work the other tools do not cover. It opens url (or goes on in tab), then for at most maxSteps (8) steps: reads the tab with browser_snapshot (mode: "interactive", at most 8,000 characters, with the URL, the title and the last action's result), asks the node's chat model at low thinking (the fast model when the node runs on an AI plan) for one action as JSON (click, type, press, goto, scroll, wait, extract, screenshot, done {answer}, stop {reason}), and does it with the node's own browser tools (captureTools in node/device-tools.ts), so the computer's rules, cards and evidence apply. When an action opens a new window (a payment or sign-in popup, opened in the tool's result) it goes on in the newest, as a person would; its step and its result say so ("A click opened a new window, tab t16 (…), and it went on there; tab t3 is still open."), with popup {tab, openedBy, title, site} in its data; when that window closes by itself (a payment or sign-in that finished) it goes back to the tab that opened it. Its own rules:

  • A sign-in page (a password field, or an identity provider's own page such as login.microsoftonline.com or accounts.google.com) or a password, code or card field: browser_login_needed for the tab, and the turn parks as for any card (sign_in_needed). It never types passwords or codes.
  • A click that would buy, pay, delete, cancel, send, publish or subscribe needs those words from the owner (their messages in this turn, or their last three in this thread); otherwise it stops (blocked) and says what it would have clicked.
  • A human check stops it (human_check, with the tab, which the node can hand to its owner).
  • How it moves through a site (after OpenClaw's browser-automation skill and browser-use's browser rules, both MIT): the site's search box, filters and sort before scrolling long lists; an action that did not change the page is not repeated (another element, a scroll, another way in); "Access denied", a 403 or an empty page stops it (blocked) without a retry; before done it checks that the page shows what the goal asks.
  • An older computer that refuses mode (or focus, purpose) is asked again without it.

It returns {status: done | sign_in_needed | human_check | blocked | gave_up, answer, steps, evidence, tab, url, title}; its screenshots show with the reply. Each of its model calls is a model.call metric for "Ensemble Node", charged like the turn's own calls on included models. The turn's Stop, a merge or a park ends it between steps.

The answer check (node/deliverables.ts). A conversation turn its owner started, where only the owner reads, gets a checklist from the owner's new messages: screenshots and how many ("4 screenshots", a list of names, "each store" with the stores named), a table, a file to add to their Ensemble Files ("add/save/put … (my) (Ensemble) files"), the place prices are for (a US state, "City, ST", a ZIP, a country), a file sent into the chat, links. While the turn runs, one checklist call to the fast model at low thinking (strict JSON over those kinds; skipped for chit-chat; ignored when it fails or is not back 3 seconds after the answer; ENSEMBLE_NODE_CHECKLIST_MODEL=off turns it off) adds what the rules miss. When the model gives its final text, runAssistantTurn's verify hook checks it against the checklist and what the turn did:

  • the pictures that will show with the reply (peekTurnMedia; captures held for the owner's preview count);
  • a Markdown table;
  • a successful hub_save_file whose file exists;
  • the place in a tool's input (location, a ZIP, the words, or browser_set_location);
  • every ensemble:// link in the reply is one of the owner's records (validateRefs, node/refs-check.ts);
  • no claim ("attached", "here are the screenshots", "added to your files") without the tool results behind it;
  • no work said, promised or asked about instead of done (node/reply-check.ts, patterns only): "I've delegated it" with no tool that changed anything, "I'll resume the session now" (reporting back later on work a tool started is fine), "@swe-1 please start" without node_delegate, node_plan or a session tool, "Do you want me to …?" in the first round, and the model's own drafting notes.

What is missing goes back as a user message ("Not done yet: … Do it now with your tools, or say plainly what could not be done and why."), at most twice per turn and only while a step with tools remains (the 8-step cap holds); the reply being written starts again on the website. A shortfall the reply plainly explains stands once a tool failed or after one round, and so does a question to the owner after one round (the first round pushes a small model to act instead of asking). Work handed on (a plan, a session, background work) or waiting for a card is not checked. Plan steps have their own checks (§6.3).

Crews stay crews. A plan's steps and node_delegate reach only the crew's members and the node's own helpers (node/crew.ts, checked again in node/plans.ts); a delegation from a DM goes to the crew the node is in. A node joins a crew only when the owner approves a node_crew_add card, which any node asks for in a turn its owner started, for another of their nodes or for itself, for its crew or the one the owner names (data {type: "crew_add", status, nodeId, nodeSlug, nodeName, roomId, crewTitle, reason, answeredAt, handle}, settled through POST /api/node/approvals/:messageId): approving adds it, discarding leaves it out, and the node that asked is woken either way.

Helpers (node/helpers.ts). node_helper makes a node with parent_node_id set, effort low and the parent's model, whose charter is the task, and a task room (settings {parentNodeId, helperNodeId, originRoomId, originPrivateTo}) with the owner (to read), the parent and the helper: the helper's thread, which the website shows inline where the parent was asked (the room view's helper: parentNodeId, helperNodeId, originRoomId, originPrivate, originMessageId (the message that asked) and planId (the plan card its step is in)). The brief opens the thread without waking anyone, and the task becomes a step of the parent's plan for that turn (executor: helper, §6.2): helpers brought in together are steps of one plan, so the parent hears once, with every result, when they have all reported, and tells its owner in one message. A helper:

  • runs with no write areas and no connected-app writes. With can: read (the default) it only reads: workspace search, pages, tasks, notes, connected apps, the web. With can: sessions it also runs its own coding-agent session on the owner's computer (the session tools); with can: computer the computer tools (§8.13), only in the folders the parent named (folders; none named: the same as its parent). A helper never gets more than its parent (autonomy.grant);
  • does its step in its thread (a work turn) and reports with node_step_report, which code also posts in its thread; it never posts where the owner talks. Asked again later (node_delegate to its own helper), it answers in its thread by mentioning its parent;
  • is not in the node list, the sidebar's nodes, the CLI overview or the 24-node count, and cannot join a crew or room.

In a task room the parent's own reply goes to originRoomId (where it made the helper) instead, in the same thread: privately when it was asked privately (originPrivateTo); if the node or its owner has left that room, to its DM. People cannot post in a task room (403). A node has at most 4 helpers working at once, and the account 8 (a turn going on, or their room active in the last 10 minutes): the owner changes this per node (autonomy.helpers; the account allows twice it; 0 for no limit). Finished ones stay for the owner to read, 12 per node, the quietest going first. A task room quiet for 3 days goes with its helper (sweepHelpers, every 10 minutes); deleting the room, or the parent, removes the helper at once; pausing the parent stops its helpers' turns too.

Keeping a helper. POST /api/node/nodes/:id/promote keeps a helper as a lasting node: it leaves its parent, keeps its charter and memories, and gets its own DM; its task room stays as history.

Lasting nodes and crews. node_create and node_create_group propose what the website would make (createNode, createCrew): an owner-only card (data.type: "node_proposal", action: node | crew, input) with the name, role and charter (and the handle and look, when it gave them), or the crew's nodes and lead. Approving it makes exactly that, with the same unique-name rules and limit, and the node hears it; the new node's chat appears in the owner's sidebar and the node can hand it work with node_delegate. An owner who sets the node's autonomy.createNodes to auto lets it make them without asking.

6.2 Plans and missions

A lead hands work to its owner's other nodes as one plan, never as chat between nodes; a node's own background work is the same thing with one step. Both are missions (node/plans.ts, node_missions and node_steps), whose card is a plan message with the mission's id. The card's data is a view of the tables (project in node/plans.ts), rebuilt on every change.

  • Making it. In one turn the lead calls node_plan with the whole plan (§6.3), or node_delegate per step. Only a turn its owner started may. The card (owner-only where other people read) is posted in the crew, or in the crew the lead leads when it was asked elsewhere. A step has {node, kind, brief, after, verifies?, requirementIds, run}: kind is build, verify, write, research or other (from the node's role when not given); a verify step names the build it checks (verifies); run is the agent, model, thinking level and context the owner named. At most 30 steps, a brake on runaway planning. When the turn ends, the lead's reply opens the card, and the steps that can start, start.
  • Execution and outcome. A step's status is its execution (waiting, working, done, blocked, skipped, stopped); its outcome is its result (unknown, done, pass, fail, blocked, unverified), computed from the report's checks (verdictOf in node/report.ts), never from prose. A verification passes only when it has at least one check, every check passed and every mandatory acceptance criterion has a passing check; node_step_report refuses a report that contradicts its own checks. A step whose turn ends in prose is asked once to report (nudged); after that the attempt is unverified.
  • Order and holding. A step starts when every step in its after has the outcome done or pass. One after a step that failed, is blocked or is unverified is held (held, heldBy on the card) and never starts on its own; when the plan ends it is skipped with what held it.
  • Repair. A verify step that fails reopens the build it verifies as a new attempt (reason: repair) in the same agent session, with the failing checks and the tested commit in its note; when the repair is done with a new candidate, the check runs again as a retest of that candidate. Steps after the check wait meanwhile. A repair that changes nothing (the same commit or content hash), or the same failure twice (failureSignature, which ignores commits, paths and numbers), stops repairing: the lead gets one judgement turn; a third identical failure asks the owner. There is no fixed number of rounds. Every attempt keeps its outcome, checks and candidate on the card (attempts).
  • Steps' turns. A step's turn runs in its work lane (§5.1) with the step's note: its brief, the requirements it covers, the mission's constraints rendered by code, its run settings ("already applied; do not check them") and the results of the steps before it. It has the session tools, node_secret_request, node_remember, node_step_report and node_step_blocked. A step that started agent runs, or has a card waiting on the owner, stays working until they come back. A run's end wakes its node for the same step, unless the run reported a structured result (WorkspaceJob.report): then the step ends with it directly, with no node turn. A run, card or turn from an earlier attempt of the step changes nothing.
  • Straight to the agent. A step that is work in files, where the mission names the folder or repository (build, write, and the verify of a build or writing), skips its node's turn: code compiles a packet (node/packet.ts: Goal, Your part, Constraints, Inputs, Who you are, Report; at most 6,000 characters, every constraint once, earlier results linked rather than pasted) and starts the session itself (node/dispatch.ts). The mission's folder path is matched to a folder a paired computer shares (by name, or its last part); a single repository address is cloned instead. A repair continues the builder's own session and a retest the checker's; a builder's next build continues its session too (in serial work); a check starts a session of its own. Before it starts, the step takes its place on the computer: the folder's checkout, or its own worktree when the mission builds in parallel (§8.15); while another step has it, the step waits, its progress saying for whom. The run card keeps the packet (data.told). The worker's report_result ends the step with no model turn. A run that ends without a result is asked once, in the same session, to report (data.reportAsked); after that, a blocked report, a failed run, or a hand-over that could not happen (no shared folder, a refusal), the step's node takes a turn with the step and the reason in front of it. "Ask me before computer work" still holds the packet for the owner's card. Research and other steps, and a node's own work, are turns of their node as before.
  • Report. When nothing can move (nothing working, nothing ready) the mission is reporting and the lead gets one turn where it was asked, with every step's result, failed checks, the requirement ledger and anything that needs its judgement. It may continue the plan (node_delegate, node_plan_revise); otherwise the mission ends succeeded (every step done or passed and every requirement accepted), partially_completed, failed (a check failed) or stopped.
  • Durability. Every change runs in one transaction that locks the mission row, applies the pure rules in node/missions-core.ts, stores the steps and writes its effects (start a step's turn, the lead's report, expire cards) to node_outbox. The outbox (node/outbox.ts) delivers them right after the commit and in a sweep every 5 seconds; a turn it enqueues carries the effect's operation_id (unique on node_turns), so a redelivered effect starts nothing new. Plans made before this change continue as contract 1, where a finished step keeps its old meaning.
  • Stopping. POST /api/node/plans/:id/stop, the room's Stop, pausing or deleting its lead stop a plan: its turns stop, steps that have not ended become stopped, the runs they started are stopped and wake nobody, cards waiting for the owner expire, and nobody reports. Pausing or deleting a step's node stops only its steps.
  • Cards of ended work. When a step or a mission ends, its approval and secret cards still pending become expired ("Its step ended."); answering one then changes nothing.

6.3 The mission contract

node_plan takes the whole plan at once (node/plan-contract.ts):

Field Meaning
goal One line.
requirements One per deliverable the owner asked for: {id, text, acceptance: [{id, text, mandatory?}], evidence?}.
constraints Written once for every step: computer, folder, repos, branch, publish (none, commit, push, pr), environment, agent, model, effort, context, substitution (forbid, ask, allow), order (serial, parallel_ok), parallel (serial, worktrees, snapshots: how builds share the folder, §8.15), integrate (merge, rebase, patch: how an integration step combines them), notes. Agent settings become each step's run settings.
steps {id, to, kind, requirementIds, brief, after, verifies?, integrates?, run?}; briefs at most 1,500 characters, the step's own objective only. integrates makes a build step the integration step for the builds it names (§8.15).
deferred {requirementId, reason} for what will not be done now.

Validation (validatePlan) refuses: unknown or duplicate ids; cycles; a requirement no step covers and not deferred; a requirement with acceptance criteria but no verify step; a verify step without a build before it; under order: serial, a build step covering more than one feature (an integration step aside), or a feature not after the previous feature's check; with parallel: worktrees or snapshots, no folder, two or more builds not each brought in by one integration step, an integration step that is not a build, comes before a build it integrates or that build's checks, or has no verify step after it when those builds had checks, and integrate: merge or rebase for snapshots (they have no branches); integrates in serial work. The card shows a requirement ledger (open, in_progress, accepted, failed, deferred, cancelled). node_plan_revise changes a running plan: merged constraints (an empty string clears one) apply to every later step note (a new parallel to steps that start after it), steps can be added (their after naming new ids, existing step ids or @handle), waiting steps cancelled, requirements added or deferred; the revision number goes up.

6.4 The stop path

POST /api/node/rooms/:id/stop {scope?: room|all}, or a message that is only stop, stop all or stop everything (node/stop.ts), stops work at once without a model: the replies in progress, the plans started here and the agent runs started here (all: everything the owner's nodes in this room do, in every room). The owner gets one private status line ("Stopped 1 plan and 2 agent runs. What was already done stays.").

6.5 Routines

A routine is work a node does by itself on a schedule, when something changes in its owner's Ensemble, or when a webhook calls (node/routines.ts, node_routines and node_routine_runs).

Field Meaning
instruction or template What to do each time: an instruction the node does as its own work, or a saved plan (a node_plan input, §6.3) its nodes run as a plan. One or the other.
trigger schedule (cron, five fields, in timezone, the owner's by default), event (event: {on: task|page, actions?, space?, windowSeconds}) or webhook. Any routine can also be run now.
notify always (the node posts the result), needed (only when something failed, changed or needs the owner) or never (nothing is posted; the run is listed with the routine).
roomId Where it reports: the node's chat by default, or one of the owner's crews it is in. Never a room for other people, even while its owner is alone there.
status active or paused.
  • Occurrences. Each has a key unique per routine (at:<time>, event:<window>, hook:<window>, manual:<id>), so a restart or a second server records and starts it once. A scheduled time missed by more than an hour, because Ensemble was not running, is recorded as missed rather than run late. A tick every 20 seconds (ENSEMBLE_NODE_ROUTINE_TICK_MS) records the times that came and starts what is due.
  • Bursts. Changes (Ensemble's own live frames for tasks and pages in the watched space) and webhook calls arriving within a window gather into one occurrence with every change listed, at most 50. Each change is added to its occurrence in one statement, so changes arriving together are all kept; one arriving after its window's occurrence started opens a follow-up occurrence. Changes made while the routine's previous run is still going are its own and start nothing. A run still going when the next one is due makes it wait.
  • What runs. An occurrence runs through missions like any other work (§6.2): an instruction as the node's own work, with the time and what happened as information, never as instructions; a template as a plan in the node's crew. Its card is listed with the routine, not in the chat (its plan_id is the routine's id), so only the report is a message. The report turn knows it is a routine and its rule.
  • Nobody waits forever. A routine's work is background work: a question its agent asks on Needs me, and a card its step raises, wait 30 minutes for the owner (ENSEMBLE_BACKGROUND_DECISION_MINUTES) and then expire, saying so; the step's node goes on without the answer or reports what it could not do. Work the owner started in a chat has no such timer.
  • Webhooks. POST /api/node/hooks/routines/:id with Authorization: Bearer <token> (or ?token=). The token is shown once, when the routine is made or the token renewed, and only its SHA-256 is stored. Every refusal is the same 403. A delivery id (X-GitHub-Delivery, Idempotency-Key, X-Request-Id, else a hash of the body) seen in the last day starts nothing. The body (up to 8,000 characters kept) reaches the node as information.

6.6 Tasks given to a node or a crew

A person gives an Ensemble task to one of their own nodes or crews from the task's Owner field in the Hub (02 §13). The node, or the crew's lead, is told once and decides how to do it (node/task-assignment.ts).

Field (tasks) Meaning
owner node: the task is with a node or a crew (besides me, agent and unassigned).
assignee_node_id The node, when it is with a node.
assignee_room_id The crew (a crew room), when it is with a crew. Exactly one of the two is set with owner: node; any other owner clears both. Migration 20261120150000_task_node_owner.
  • Giving it. PATCH /api/tasks/:id (or POST /api/tasks) with {owner: "node", assigneeNodeId} or {owner: "node", assigneeRoomId}; naming one alone means owner: node. Only the acting person's own lasting nodes and their own crews that are not deleted: another person's node or crew is a 404, a person and a node together or both ids a 400. Any other owner (me, a person, agent, unassigned) takes it back.
  • In a shared space. Anyone who can edit a task there may give it to their own node or crew, never to someone else's, so a node only ever gets work from its own person. Anyone who can edit it may take it back or give it to someone else, as with people; the node that had it is told to stop. Everyone who sees the task sees whose node has it: task responses carry nodeAssignee {kind: node|crew, id, name, handle, accountId}, a node by its public name and handle, a crew's title only for its own person ("Crew" for others).
  • GET /api/node/assignable. What the owner field offers: {nodes: [{id, slug, name, role}], crews: [{roomId, title, lead: {id, slug, name} | null, members}]}, the signed-in person's own, whichever space is open; empty without nodes. A crew's lead is its set lead while it is a member, else its manager, else its first node.
  • Telling the node. Once the write commits, code (no model) queues one turn with reason task (operation_id task:<task>:on|off:<at>:<node>, so it is never queued twice), working in the task's space by default: a node in its DM with its owner (made again if it has none), a crew's lead in the crew's room. The note carries the task as a link ([Title](ensemble://task/<id>)), its status, priority, due date, project, labels and what it says (information, not orders), what Ensemble knows around it (aroundTask: the meeting it came from and the words asked, people and their roles, dependencies and related work, from context/graph/packet.ts, about 1,600 characters; hub_item_context reads more), and how to decide: as its charter, memory and owner's instructions say (an agent, model or run profile they told it to use first), do it itself, run it in an agent session (node_session_start with taskId for a task in the owner's own space), or plan it with its crew; a lead picks members by role (a testing task to the tester). It replies once, in a line or two, then works, moves the task's status with hub_update_task as work goes, and links it. Its reply carries data.task {id, title, status, due} and the task in data.refs; a reply that opens a plan is the plan's card.
  • A moment to undo a slip. A task given from Ensemble by a person shows its node thinking at once (node.status), but its turn starts 3 seconds later (ASSIGN_GRACE_MS, the turn's run_after). Changing the owner in that moment (to anyone, or another node or crew) drops the waiting turn: no model call, no word to the node. Later, a turn about the task that has not changed anything yet is stopped where it is (withdrawTurn in node/queue.ts, abort reason withdrawn) and leaves no trace; only a turn that already acted, or work it started (a plan, an agent session), makes the node be told to stop. GET /api/node/tasks/:id/work says what a change would stop ({work: {node, doing: thinking | plan | session} | null}): the Hub's owner menu changes at once when it is null, and otherwise asks first in place ("@scout has an agent session on this. Changing the owner stops that work." with Keep working and Stop and change).
  • Taking it away. Another owner, node or crew tells the node that was told (in the room it was told in) that it is no longer theirs, who has it now, and the plans it made for it and the agent sessions working on it, to stop with node_work_stop and node_session_stop; it says one line only if it stopped something. A node that gives the task back itself, or a member of the crew that had it, is not told. A change to anything else on the task tells nobody. Undo of a change of owner tells them the same way (lib/undo.ts).
  • Its own runs. A run a node starts for a task with a node or crew keeps the task with them (workspace/assign.ts); the worker's automatic status moves are for the agent's tasks, so the node moves this one. A person who runs it with the agent ("Agent does it") takes it from the node, which is told.
  • The work board. Every conversation turn's work board (§5.1) lists up to six open tasks with the node, or with a crew it leads, as links with their status, due date and space. The persona has no extra row.
  • Gone. Deleting a node, or a crew, leaves its tasks unassigned. A paused node is not woken; its work board shows the task when it is resumed. Each telling is in the node owner's ledger (node.task_assigned, node.task_unassigned).

7. Approvals

POST /api/node/approvals/:messageId {decision: approve|discard, callIds?, body?}. Only the card's owner can settle it, and only while they are still in the room. A card is settled once: it moves from pending to applying with a JSON-guarded update. Held tool calls are applied by POST /api/assistant/apply with the node's conversation (same dedupe, locks, undo entry and ledger as the Hub). For a node's conversation Apply checks each call again in the space it names, with the owner's access at that moment (conversationSpaces in node/spaces.ts, §5.4). If that fails, the card returns to pending with the error so the owner can retry or discard. Discarding marks the calls failed with "Not approved by the person." in the conversation. Approving a draft posts exactly the approved (optionally edited) words, which then speak for the owner. After an approval, the node gets a follow-up turn with the result so it can continue its plan.

8. Work on a computer

Coding work on a computer goes to the owner's own coding agents on their paired computer, on their own subscriptions, through agent sessions (30, Agent sessions): a session is one conversation with one agent, carried across the runs that continue each other. Every run is a WorkspaceJob made by createJob, so Ensemble's queue, device protocol, sandbox, decisions and publishing rules apply as for any task (06, 25). Everything else a node does on the computer itself (§8.13): commands, downloads, files and reports, terminal screenshots, installs as the administrator, and the browser and capture (§8.16).

8.1 Starting a session

node_session_start plans the run as hub_assign_to_agent does (plan in assistant/tools/agent-assign.ts): the node's run profile (runProfileId, else the owner's default; or the one named) decides the agent, model, thinking level, context, computer, mode and git rules unless the node names others. The run card's data names them (engine, model, effort, context). With no coding agent in the profile, it takes one signed in on the chosen computer. The agent works in a folder the computer shares, a fresh clone, or a new empty folder.

A run the node starts on its own keeps its record on an internal task (sourceRef node:<nodeId>, lib/node-tasks.ts) that stays off the board: the board, Today, the assistant's task lists and the CLI's task list leave it out (ON_BOARD). The run still shows in Runs and Needs me. When the owner wants the work on their board, the node makes a task (hub_create_tasks) and passes taskId; the run then belongs to that task.

Each run gets a node_runs row (session_id, the node's prompt, direct for an off-board task) and a run card in the room, owner-only in rooms with other people. A new session's id is its first run's job id.

A model named by its label ("GPT 6.1 Sol") or alias becomes the id the computer's agent lists (agentModel in node/sessions.ts, from capabilities.agents); a model the agent does not list (unless it takes typed names) and a thinking level the model does not take ("extra high" is xhigh) are refused before any run is made, with the ones it takes.

Which agent can run which model (lib/agent-fit.ts). An agent that runs one maker's models refuses another's, whether the agent takes typed names or the computer lists nothing for it: Claude Code runs Claude models, Codex OpenAI's, Gemini CLI Gemini's, Grok Build Grok's; GitHub Copilot, Cursor, OpenCode and Antigravity run several makers' (makerProblem, from the model's name; a model the computer lists for the agent always stands). The refusal names the signed-in agents that run the model. It is checked when the model is named (agentModel), on the run's final settings (refuseUnhonoured, so a profile's or a step's model counts too) and in the assistant's hub_assign_to_agent (plan()). A model named without an agent goes to a signed-in agent that runs it (agentOn); the run profile's agent is only a default and gives way when it cannot run the named model. An agent named by a person or a lead in its own words ("copilot", "GitHub Copilot", "claude") is read as its id (engineNamed), so a plan step's agent is never dropped. A named agent that is not installed or not signed in on the computer stops the call there with the agents that are (refuseUnready; a computer that lists no agents is not refused on a guess). Before 0.7.5 a step's "copilot" was not read, and its run went to Claude Code, signed out, with GPT-6.1-Sol (the Software team crew, 5 November 2026).

8.2 Which session

The node chooses (sessionRules in node/brain.ts): the same line of work in the same repository or folder goes to the session that did the earlier work, so the agent remembers it and its provider reuses the cached conversation; unrelated work, another repository or folder, another computer or agent starts a new session. node_sessions is the only list it sees.

Shown only when it starts one (node/session-fit.ts). The node's prompt carries no list of earlier sessions: most turns (a question, a look-up, browsing) start none. When node_session_start is called without newSession, a repoUrl or a plan step, it scores the node's own sessions with the same agent, computer and folder with Ensemble's session-choice scorer (workspace/session-choice, no model): shared anchors (file paths, code names, issue numbers), topic similarity weighted by rarity over the owner's other tasks (the sessions' own tasks and the new work are left out, so what they share is not counted as common), follow-up wording, a warm cache and how full the session is. A session in band unsure or better that is not too full is a fit. With fits, nothing starts: the answer is "Not started yet", with at most 3 sessions, each with what it did and why it fits ("shared: src/auth/session.ts, rate limiter"), and the two ways on: continue one with node_session_send, or call again with newSession: true. Continuing keeps what the agent learned and its prompt cache, costs less, and leaves fewer sessions in the person's agent. Tests: node/session-fit.test.ts, session-control.integration.test.ts.

A node's own sessions stay together. A fresh run the node starts for itself in a coding agent (no repository, folder or session to continue) works in the node's project folder (nodeProjectBrief in workspace/agent-projects.ts: "<Name> (Ensemble Node)", with its role and charter as the brief), the same folder its conversation uses when it runs on an AI plan, unless orchestration.projectFolders is off. A session the node started for itself and has not used for 7 days is deleted from its agent (30, Cleaning up sessions).

A session that no longer exists. When the owner deleted a session in the agent's own app (or the agent expired it), node_session_send still works: the agent cannot reopen it, so the run goes on in a new session in the same folder with the brief and the earlier task's summary, and the node's run note says the agent did not remember the earlier conversation (30, Agent sessions). A session deleted in Ensemble is gone from node_sessions, and node_session_send refuses it. The node's own conversation on an AI plan whose session was deleted goes on in a new session with the conversation so far.

Isolation. A session belongs to the node of its first node run (sessionOwners). Other nodes never see it, and node_sessions, node_session_send and node_session_stop refuse it. A session started outside Node (the Hub, the CLI) belongs to no node until one continues it; then it is that node's.

8.3 Queue, steer, replace, stop

Continuing a session needs Ensemble 0.4.18 or later on its computer (capabilities.sessions, else the app version; continuesSessions): older runtimes open a new agent session for every message, so node_session_send is refused there with the update to make. A computer that never reported its version is not refused.

node_session_send delivery:

Delivery What happens
queue (default) A new run at the end of the session's queue. It waits for its turn (sessionTurn, §8.9), so messages run in order. On an idle session it runs now.
steer Changes the run going on. A new run with context.steer {update} continues it and goes first in the queue; then the run is stopped. On the computer the agent resumes its conversation and gets only the change (steerPrompt in workspace/engine-watch.ts; the claim spec carries steer). A run not claimed yet, or a change still waiting to go in, takes the new change into its brief or its steer instead. On an idle session it is queue.
replace Queues this after the last run that reached the computer, then takes what is queued out of the queue and stops the run going on. If the new run cannot be made, nothing stops.

node_session_send takes the same run settings as node_session_start: model, effort, context and fast. Each one left out is kept from the session (§8.10).

The order is the session's queue (§8.9), not when runs were made. A new message starts from the end of the chain (lastJobId, workspace/sessions.ts); when it is claimed it continues the run that actually ran before it.

node_session_stop stops the run going on, and the queued ones unless queued: keep. node_session_queue {session, order | cancel} reorders the queued messages or takes some out, as the owner can on the card. Runs a newer call took over (steered, replaced, stopped, taken out, paused) get data.quiet on their card and do not wake the node when they end.

8.4 Who decides

Where only the owner reads and the owner started the turn (a DM, a crew, their private thread; one of their own nodes by delegation), these run at once, and the node also gets node_session_queue and node_answer_question (§8.11). With the node's Work on your computer: Ask me first (autonomy.computerWork: "ask"), node_session_start and node_session_send post an owner-only approval card (data.type: "session") instead, run exactly as shown when approved. In a room with other people, the shared thread's turns are room-bound and get no session tools at all, even for the owner's own request (§12.3): work on the computer is asked for in My crew, or arrives as an accepted peer request (§12.6). Ensemble's Stop everything refuses new work.

When a run ends (succeeded, failed, cancelled, interrupted), the watcher (node/runs.ts) wakes the node in the same thread with the summary and what is still queued in the session, so it reports to whoever asked. Questions and permissions a run asks are answered on its card in the chat (§8.6).

8.5 Pausing a node

PATCH /api/node/nodes/:id {status: "paused"} stops everything the node is doing: its reply, its queued turns and every run it started that has not ended (stopNodeRuns). It then posts in its DM, without a model call, what it stopped: the runs that were working and the ones it took out of the queue, or only that it paused when nothing was running (pausedNotice). It resumes with {status: "active"} or with its owner's next message to it (§4).

8.6 Cards in the chat

What a node or its runs need from the owner is a card in the conversation where the node works, only the owner sees it, and the node waits for it. Node has no separate Needs me page.

Card (data.type) Asks for Section
computer A folder (or, with Ask before a node opens a new website on, an address) on the computer, the first time §8.13
run_decision A question or a permission the agent of one of the node's runs asks §8.6
sudo One command as the administrator, with the owner's password §8.13
secret, login, media_preview A secret, a sign-in or a human check, a held capture §8.7, §8.16
tools, session, node_proposal, crew_add, post, peer_request Held writes, work on the computer to approve, a new node or crew, a node for the crew, a draft, a request §7, §8.4, §6.1, §12
  • The computer card keeps its decision in agent_decisions (tool name Computer tools), so the phone and ensemble needs answer it too; the card (data {status: pending|allowed|denied|expired, decisionId, capability, label, scope, deviceName, call, options, answer, answeredAt, private, plan?}) follows whichever answer comes first and is answered with POST /api/decisions/:id/decide.

  • A run's questions and permissions (node/run-decisions.ts). When the agent of a run a node started (node_runs.job_id) asks (POST /api/devices/self/jobs/:id/ask, or a run on this server), its decision gets one card in the run card's conversation and thread, by the run's node, owner only (data.type: "run_decision", data {status: pending|answered|expired|closed, decisionId, jobId, runTitle, event: permission|question, title, command, cwd, options, allowCustom, recommended, answer, decision: allow|deny|null, answeredAt, answeredBy: owner|node|elsewhere|null, computer, confirm, computerOnly, private, plan?}). It is answered as any decision, with POST /api/decisions/:id/decide: a permission with scope: "once" (confirmed: true when confirm; one that is computerOnly is answered on that computer), a question with an option or the owner's own words as reason. The card follows every answer from anywhere (the decision bus): the owner's through the decide route (owner), the node's to a question of fact (§8.11, node), the computer's or the agent's own (elsewhere); its expiry (expired); and the run's end (closed, from the run watcher in node/runs.ts). After a restart the waiting cards are followed again. The decision's notification opens the card in the Node chat.

  • The node waits. A tool call that needs an answer waits in its turn, at most ENSEMBLE_NODE_PERMISSION_WAIT_MS (3 minutes), and gives its slot back meanwhile (waitingForOwner in node/queue.ts); the website shows the node waiting (node.status waiting, with the card). Allowed: the same call runs again at once, at most three asks for one call, and the turn goes on; denied: the node hears it and goes on without.

  • Parking. No answer in time, or a new message from the owner in that conversation: the turn parks. It ends after the tool calls it is making, posts nothing (requestPark, outcome parked), keeps a note of what it waits for in its conversation, and a plan step stays working. The answer, whenever it comes, wakes the node with the exact call to make again (announce in node/device-tool-decisions.ts). One question waits for each conversation or step that parked on it, and each is woken.

  • Checkpoints. Once answered, the website shows a card as a small checkpoint in the conversation ("Allowed @web to run commands in live-test · Once"), which opens again on a click.

  • Ensemble's other surfaces. The Hub's Needs me shows of Node only what a node's sessions ask for, as the session's own request (node/needs-me.ts); the node's own cards are answered in the Node chat only:

    Request Raised by In the Hub's Needs me
    A run's question or permission (its decision; the run_decision card mirrors it) The session's agent Yes, under Your agent is asking: GET /api/decisions?surface=hub, each run's request with origin {agent, folder, computer, task, node {slug, name, roomId, room, url}} (for the space's owner; url opens its card, else its run's card)
    A run's sign-in or held captures (login or media_preview with the run's jobId) The session's computer, for its agent Yes, under From your nodes' sessions: GET /api/node/approvals, each with its origin, settled with POST /api/node/approvals/:messageId
    computer (its decision, tool name Computer tools), sudo, secret, tools, session, node_proposal, crew_add, post, peer_request, disclosure, and the node's own sign-in or captures (operation instead of jobId) The node No

    /api/shell counts exactly these rows in decisions and nodeApprovals (hubApprovalCount in node/routes.ts). The CLI lists decisions in ensemble needs and, on its Node tab (GET /api/bridge/node), every card except computer, run and administrator cards (their decision is the request, or only the card in the chat answers one). GET /api/node/activity lists every pending card, for the website's "needs you" marks. A card created or settled sends approval on the owner's Ensemble stream (needsMeChanged in node/store.ts), and a request answered, expired or closed sends decision; the Hub re-reads its lists, and Node gets either as node.needs. A computer or run card's notification opens it in the Node chat (ENSEMBLE_NODE_WEB_ORIGIN).

  • The owner chooses. Show my nodes' requests in Ensemble on Node's Settings (settings.node.needsInEnsemble, on by default) decides whether the rows above marked Yes reach the Hub at all: off, GET /api/decisions?surface=hub, GET /api/node/approvals for the Hub, /api/shell's counts and the bell leave them out (withoutHiddenNodeRequests, nodeNeedsInEnsemble in node/needs-me.ts), and they are asked only in the node's chat. Either way a request answered, expired, errored or stopped anywhere leaves everywhere at once, the bell's "needs you" notification included (notifications/needs.ts); a failed run a node started leaves no "Agent needs you" notification in Ensemble, as the node reports it in its chat.

  • A reply in the chat answers. When the owner writes in the conversation a run's card is in (in a room with other people, only in My crew) while that run's agent waits, the message is the answer (node/chat-answers.ts), through the decide route's own path (applyAnswer), so its card and the Hub follow: a question takes the option the reply names, or the reply itself when its agent takes a typed answer; a permission is allowed by an explicit yes ("yes", "ok, go ahead"; one that must be confirmed waits on its card) and denied by an explicit no ("no", "not now"). Anything else is not an answer and the request waits on its card, so a message about something else never refuses what an agent asked. Only one request is answered: the one the reply quotes (its card, or its run's card), else the only one waiting in that conversation, or the only one whose option the reply names; one only its computer may approve is left alone. The chat gets an owner-only line saying what was sent, the run's log and the session record it ("Your owner answered … from their reply in the chat" on the node's work board), and the node still reads the message.

  • A run's questions and permissions close when the run ends, and the first answer from anywhere wins; a late answer is refused with "This request ended with its work." (05 §5). The run watcher (node/runs.ts) also ends the run's sign-in cards still waiting (pending or needs_code, to ended) and sends decision for each request the run's end closed, as the computer's own ends do (announceClosedDecisions in devices/settle.ts).

8.7 Secrets

Work often needs a secret (an API key, a token). No model is ever given one, and Ensemble on the web never stores one. Where the value is typed, how it travels, where it is kept and how runs use it are separate steps, each below. §8.7.6 says what this protects against and what it does not.

8.7.1 Asking

  • A node asks with node_secret_request {name, why, allow?} (an environment variable name; names that would change how the computer or an agent runs or signs in, such as PATH, GITHUB_TOKEN, ANTHROPIC_*, OPENAI_*, ENSEMBLE_*, GIT_*, are refused by secretNameProblem in lib/run-secrets.ts). It posts an owner-only card (data.type: "secret", with the computer: the one named, else the default run profile's, else one online; the computer must report capabilities.secrets). allow is the node's suggestion of where the secret may be sent (origins such as https://api.stripe.com), shown on the card for the owner to keep, change or clear.
  • The owner answers on the card in the Node chat (a node's own request is not in the Hub's Needs me, §8.6). GET /api/node/secrets/:messageId tells the page which computer the card is for and how the value can reach it (route.mode, below). Declining (POST /api/node/approvals/:messageId {decision: "discard"}) tells the node to go on without it.

8.7.2 Encrypted in the browser (end to end)

Each computer has its own key pair (B12):

Key ECDH P-256. Made by the computer when it is paired, and again on each new pairing; a computer paired before keys existed makes one on its first report after updating (credentials/seal-key.ts).
Where it is kept seal-key.json in Ensemble's data folder, mode 0600, the private key encrypted with the runtime's local key (secret.key beside it, lib/secrets.ts). Ensemble keeps nothing in the operating system's key store yet. Coding-agent runs and a node's commands cannot read the data folder (§8.13, 24 §3). Unpairing deletes it.
What the web gets capabilities.sealKey {kid, alg, spki, fingerprint}: the public key (SPKI, base64url), its id and its fingerprint, both the SHA-256 of the SPKI (16 hex digits; the fingerprint in four groups, 7F98 5267 E5FD 5A05).
Pinning The first key a computer reports is pinned on its devices row (seal_key, credentials/device-key.ts) and the web encrypts only for it. A later report of another key changes nothing: the computer shows Secret key changed: pair it again, the card says the same, nothing is sent to it, and the ledger notes device.seal_key_mismatch. A new pairing is a new row with a new key. The web works out the id and fingerprint from the key itself.
Comparing ensemble secrets fingerprint and ensemble status on the computer; the secret card and the computer's details in the Hub (Settings → Connected computers) and on Node (Settings).

The box (sealFor in @ensemble/shared-types seal.ts for the Hub, an identical copy in Ensemble Node's lib/seal.ts; opened by credentials/seal.ts on the computer):

Part
Key agreement A fresh ephemeral P-256 key per box, ECDH with the computer's public key
Key derivation HKDF-SHA-256: the shared secret, salt the ephemeral public key (raw, 65 bytes), info `ensemble-seal-v1
Encryption AES-256-GCM, a random 12-byte IV, 16-byte tag; the plaintext is {value, allow?}
Associated data {v, purpose: "secret", kid, deviceId, decisionId (the card's id), name, origin (the page's own origin), expiresAt (10 minutes), nonce}, in that order
On the wire {sealed: {v: 1, kid, epk, iv, ct, aad}}, every binary field base64url

The browser checks the key's id is the SHA-256 of the key it was given, and shows the fingerprint of the key it actually encrypts for. POST /api/node/secrets/:messageId {sealed} checks the box's shape and size, and that its associated data names this card, its secret, this computer and its pinned key, the page's own Origin and an expiry within 15 minutes. It then sends the box as it came to the computer (secret.save {name, decisionId, sealed}, askDeviceFor, 20 s). It never has the value: there is nothing to log or keep.

The computer opens it and checks again, for itself (remote/secrets.ts): its own device id and current key, the card it answers and the name, a site it trusts (Ensemble on the web and Ensemble Node for the server it is paired with: app./node. of an api. server, <name>./node-<name>. of an api-<name>. server, or ENSEMBLE_SEAL_ORIGINS; credentials/origins.ts) and the expiry. Each box is taken once (credentials/replay.ts, seal-seen.json in the data folder): a nonce seen before is refused, and so is any box for a card already answered there. A box that fails to save can be typed again; the same box cannot. A refusal goes back to the card with its reason (seal_device, seal_origin, seal_expired, seal_replay, …), and the ledger there notes secret.refused with the reason.

8.7.3 An older computer (compatibility)

A computer that reports no key (route.mode: "tls") gets the value as before: POST /api/node/secrets/:messageId {value, allow?} sends it over TLS to Ensemble's server, which passes it to the computer (secret.save {name, value}) and keeps and logs none of it. The card says so plainly: "Sent over HTTPS to Ensemble's server, which passes it to ‹computer› without keeping it. ‹computer› is too old for end-to-end encryption; update Ensemble there." The server refuses a plain value for a computer that has a key (seal_required), and a sealed box for one that has none (seal_unsupported). The saved card records which it was (data.sealed).

Saving and declining change the card by compare-and-set (updateMessageDataIf), so only one wins; a card a restart left saving can be answered again after 30 seconds. An offline computer, or one that does not answer, leaves the card pending with the reason.

8.7.4 Kept on the computer

The computer keeps the value encrypted with its own key (run_secrets, lib/secrets.ts), with where it may be sent (run_secrets.allow, origins). Every value it knows is hidden in anything it sends to the server (hideSecretsDeep in remote/client.ts) and in every redaction (lib/redact.ts); it loads them for this at start-up (rememberRunSecrets), before it sends anything. The person manages them on the computer: ensemble secrets, set NAME [--allow ORIGIN], remove NAME, allow NAME ORIGIN…|--none, mode, fingerprint (26), or the local /api/run-secrets routes.

8.7.5 How runs use a secret

A secret is one of two kinds, by whether it has allowed destinations:

Kind Used In a run's environment
By reference (it has allow) secret_http {ref, url, method?, headers?, body?}: the computer makes the request with the value put in where {{NAME}} appears in a header or the body, only to the secret's allowed origins (https, or http to the computer itself), never in the address, without following redirects; the answer (64 KB at most, cookies dropped) has the value hidden as written and in its usual encodings (URL, form, JSON, hex, base64 at any offset). Git over https to an allowed host signs in with it (below). Each use is in the ledger (secret.use: name, origin, how), never the value. Never
Environment (compatibility) As $NAME. Codex gets them through shell_environment_policy.set, since it leaves *KEY*/*TOKEN* variables out of commands. The agent's brief names them and says never to print one. A program the agent runs can read them. When the run may have it

Which secrets a run may have:

Run Gets
An Ensemble Node run Only the names it carries (context.secrets, the claim's secrets; 25 §4.9), possibly none: the names saved through this node's own secret requests or any secret card in the conversation, the names the mission's constraints list (node_missions.constraints.secrets) and the names its plan step's run settings list (node_steps.run.secrets, or the step on an older plan card) (secretScopeFor in node/secret-scope.ts, where node/sessions.ts queues the job). None by default.
A run whose run profile names secrets (RunProfile.secrets, the Hub's run profiles and Assign, hub_assign_to_agent, the CLI) Only those.
Any other run from the Hub or the CLI The computer's mode decides (credentials/mode.ts): all, compatibility mode and the default, gives it every environment secret; named (ensemble secrets mode named) gives it none.

The same scope decides which reference secrets a run may use. secret_http is on every run's ensemble MCP server (30 §9), and for a node it is a tool on its owner's computer (node.tool.request, tool: "secret_http", capabilities.secretHttp; §8.13): a node names the secret in the call, may use any saved one only to its allowed destinations, and only in a turn only the owner reads and started. A secret with no destination is refused with how to allow one (ensemble secrets allow NAME https://…); only the owner allows one, on the computer or on the card. computer_run's secrets puts environment secrets in that one command's environment only (§8.13), and refuses a reference secret.

Git. A run that names a reference secret allowed for a git host (https://github.com) gets git settings in its environment (GIT_CONFIG_COUNT/KEY/VALUE, the same as git -c on every git it runs): for each such host the helper list is emptied and Ensemble's credential helper set (credentials/git-credential.ts). When git needs a password there, the helper asks this computer's Ensemble over loopback (POST /api/engine/git-credential) with a token made for that run; it answers (x-access-token and the value) only while the run goes on, only for a host the secret allows, only for a secret the run names, and stores nothing. A run that names no secrets (compatibility mode) gets no git sign-in from them. The helper needs curl and a sandbox that lets git reach the computer's own loopback address.

When saved, the node hears the name only, and how runs use it: as $NAME, or by reference to its destinations.

8.7.6 What this protects against

Threat
Someone who can read Ensemble's servers, database, logs, Redis, backups or the traffic behind TLS, or a mistake that logs a request Protected for a computer with a key: the server only ever has the box. A box is bound to one computer, key, card, name, site and ten minutes, and taken once. For an older computer it is not: the value passes through the server's memory and Redis on its way, though it is never logged or kept.
A stolen device token It cannot swap in its own key (the first key is pinned until a new pairing), so it cannot get secrets typed for that computer. It can still act as the computer in every other way until the computer is removed.
A model, or a prompt injection in a page or file It never sees a value. A reference secret goes only to its allowed origins, and redaction hides the value if an answer echoes it. An environment secret (compatibility) can be read and sent anywhere by a program the agent runs; only the run's sandbox and network setting limit that. Git's helper gives the token to git, and a command the agent runs could ask it the same way, for an allowed host while the run lasts.
A compromised Ensemble on the web Not protected. Ensemble's servers serve the web pages that do the encrypting, so a compromised server could serve pages that send the value elsewhere, or show another key with its own fingerprint. Comparing the fingerprint with ensemble secrets fingerprint catches a swapped key, not a changed page. It could also make its own boxes (anyone with a public key can) and so save a value of its choosing on the computer, for a card it made. To keep a secret from the server entirely, type it on the computer: ensemble secrets set NAME.
Someone with this user's account on the computer Not protected: they can read the data folder's key files, as they can the person's other files. Ensemble keeps no key in the operating system's key store yet.

Redaction is defence in depth: a service that returns a value transformed some other way, or a part of one, is not caught. The allowed destinations are what keep a reference secret in place, so allow only the service a secret is for.

8.8 The session card

Each run in a session has its run card in the thread; the web shows one session card per session from them. Two fields keep it current:

Field What it holds
data.settings { requested, resolved, observed }, each { agent?, model?, effort?, context?, fast? } (§8.10). Observed values are "not_reported" until the agent says.
data.session { id, nodeId, status, agent, agentId, model, computer, folder, running, queue, updatedAt }: status is working, waiting (on Needs me), queued or idle; running is the current message ({ jobId, title, prompt, status }); queue the messages after it, [{ jobId, title, prompt, position }]. agent is the name shown, agentId the agent's id.

The session's cards still going and its newest card get data.session after every action on it (refreshSessionCards in node/session-queue.ts). The run watcher calls refreshRunCard(prisma, run) (node/settings.ts) as a run's job changes: it reads the job's settings again and the session's state. Both patch the card's data in one statement (data || patch), so they never overwrite another writer's fields.

8.9 Session control from the card

The owner controls a session from its card with the same functions the node's tools call (node/session-routes.ts calls sendToSession, stopSession and editSessionQueue in node/sessions.ts). The routes take the owner's browser session only: no key, no link visitor, no other person. They act as the session's node (see Handing over below), in the thread of its newest run of that node (its own chat when it has none yet), and for its plan step while that step goes on. A session no node manages is read but not controlled (409).

Route Effect
GET /api/node/sessions?q=&limit= The owner's sessions, most recently active first (20 by default, at most 50): { sessions: [{ id, nodeId, card, status, agent, agentName, model, computer, folder, title, latestTask, summary, runs, lastActiveAt, usedBy, files }] }. nodeId is the current manager or null; card is the newest accessible run card. usedBy records actual node and optional crew/room usage; direct usage has no group. files are deduplicated exact-job original references with deletion state and edit time, never bytes. Search covers displayed title, agent/model, location, state/count, node/group and paths, not hidden work summaries; it searches the most recent 100 session candidates before applying the response limit.
GET /api/node/sessions/:id { session: { id, nodeId, status, agent, agentName, model, computer, folder, running, queue, runs } }; runs are the 20 newest with their status, position, prompt, summary, settings and card (messageId, roomId)
DELETE /api/node/sessions/:id {confirmed:true} Owner's browser only. Stops running work, cancels the queue and deletes each recorded native agent session on its computer before removing the session from inventories. Keeps project files, tasks, chat messages and original-file references. Offline, outdated or failed native deletion stays visible with a retryable error.
POST /api/node/sessions/:id/control {action, prompt?, title?, queued?} queue, steer or replace with prompt (as node_session_send), or stop with queued: keep | drop (as node_session_stop); { ok, jobId? }
POST /api/node/sessions/:id/queue {order} Every queued message's jobId in the new order
DELETE /api/node/sessions/:id/queue/:jobId Takes one queued message out: it is cancelled and never starts (204)
POST /api/node/sessions/:id/hand-over {nodeId} Hands the session to another of the owner's nodes, not a helper (or gives a session no node manages to one); { ok, nodeId }

A refusal (the computer's agent cannot honour a setting, Stop everything is on, a message already started) is a 409 with what to do instead.

Permanent deletion. workspace/delete-session.ts marks the session pending under the same advisory lock as claims and continuations. New continuations are refused; queued work is cancelled and running work must end before native deletion. A successful native acknowledgement marks the root deleted, removing it from Node and Hub session inventories without deleting the user's work. Failed/partial attempts remain pending with their error; retry uses computer-side native-deletion receipts. This is not a request to erase provider inference logs or remote backups.

The session inventory includes deletion: {state:"pending", error?, until?} while an attempt is in progress or awaiting retry. The local paired-runtime inventory follows the same deleted marker, so the removed native session cannot be resumed from the CLI either.

Every affected job is marked pending before cancellation. The Node watcher closes its outstanding questions but does not enqueue cancellation reports or ask the agent for a result; an affected working plan step becomes blocked with the owner's deletion reason. Deletion cannot silently restart the stopped work in another session.

The computer advertises capabilities.sessionDelete: 1 and handles session.delete {id,jobIds} through remote/session-delete.ts, answering on the existing device-authenticated code-answer channel. Job IDs resolve only through its local held-job registry or recorded hosted-job context; browsers and models never supply native paths or native session IDs. Newer agent versions may be needed for their deletion API.

Agent Native deletion
GitHub Copilot Its headless server's session.delete, checking success
Codex / ChatGPT's Codex App-server thread/delete, not archive
Claude Code Exact session transcript and subagent directory under its configured projects history
Cursor Exact UUID chat directory under .cursor/chats/<workspace>
Grok Build grok sessions delete <id>
OpenCode opencode session delete <id>
Gemini CLI gemini --delete-session <id> in its recorded workspace
Antigravity The pinned ACP server's exact conversation .db/sidecar/WAL files and per-session brain directory under its isolated home; never credentials or shared configuration

A session the agent says it no longer has (deleted in its own app, or expired: "not found" naming that session, or Codex's "no rollout found") counts as deleted, for every agent above.

node_session_delete {session} is available only in the owner's private thread, for the node's own or unmanaged sessions. It always raises an owner-only confirmation card, even when ordinary computer work runs without asking. The node must use it only when its owner requests deletion. Declining keeps the session untouched. The Sessions page uses an in-app confirmation with the same stop/history/file-preservation wording.

The Node Sessions page is an inventory rather than a work log: identity, recency, recorded usage, two file cards and disclosure. Originals open in the existing split editor even outside a chat. Work descriptions and assignment controls stay off this page; conversation run cards retain their controls. Portrait shapes include Orb, Square, Spark, Hex, Leaf, Compass, Pebble and Bloom; all accepted creation/edit paths share these shapes.

Queue order. A session's queued messages run in the order of workspace_jobs.queue_position, separate from continueFromJobId, which stays the true resume ancestry. The session's first run always comes first; a queued message goes last, a steer first, and a reorder sets them all (queuedOrder, placeInQueue, reorderQueue, dropFromQueue in workspace/sessions.ts). A queue from before positions runs along continueFromJobId until it is next changed.

Resume ancestry. Each change to a session's queue and each claim of one of its runs (the server's queue and a computer's claim, both through claimQueuedJob and sessionTurn) holds the session's lock, a Postgres advisory transaction lock on its first run's id. A queued run starts only when no other run of its session is on a computer and it is first in the queue. As it is claimed, its continueFromJobId becomes the run of the session that actually ran last, so the chain of runs that ran is one line in the order they ran, whatever was reordered, taken out or steered. When nothing in the session ever reached a computer, it starts afresh in the session's folder and stays in the session. A reorder or a cancel touches only runs still queued, with a compare-and-set on their status; an order made from a queue that changed meanwhile is refused (409) and changes nothing.

Handing over. A session belongs to the node whose run started it, or to the node it was last handed to or taken over by. moveSession records an adopt event on the node it goes to, and sessionOwners reads the latest one. The owner hands a session over from its card. A node hands one of its sessions to another of its owner's nodes with node_session_hand_over {session, to, note?}; where the owner asked the node to check first, this waits for the owner's card. A node takes over a session no node manages (started in the Hub or the CLI) with node_session_adopt. The node the session went to sees it, sends it messages and answers for it, and its work board says who handed it over and why. The node that had it no longer sees it. Runs it already started still report to it.

The work board. Every action is recorded in node_session_events (queue, steer, replace, stop, reorder, cancel, answer, attention, adopt), with who did it (owner, node or watchdog) and from where (card, chat or watchdog). sessionEvents(prisma, nodeId, { since, sessionId }) and sessionEventLine (node/session-queue.ts) give the node's work board what happened since its last turn, so it is never surprised by what its owner did on the card.

8.10 Run settings

Every node run records three sets of settings on node_runs.settings and its card (node/settings.ts):

Set What it is
requested What was asked, as asked: named in the call, or the plan step's run settings. Nothing else.
resolved What the agent was sent: the job's own provider, model, reasoningEffort and the context window and fast mode on its context. "default" is the agent's own.
observed What the agent itself reported it runs with (workspace_jobs.observed_settings, else the job's engine and engine_context events; 30 §3.4). Anything it did not report is "not_reported".

settingsLine(settings) gives one line for people and nodes, for example "GitHub Copilot · GPT 6.1 Sol · default effort · long context (requested); observed model gpt-6.1-sol, effort not reported, context not reported". refreshRunSettings(prisma, run) reads the job again and updates observed.

Continuing a session. Each setting has three states (sessionChoices and resolveSessionTuning in workspace/assign.ts): left out, the session's is kept; "default" (or fast: false), the agent's own default; anything else, that value. Naming a model keeps the session's thinking level, context window and fast mode. The task's model tier adds nothing to a run in a session (keepSettings).

What the computer can honour. Before any run is made, the settings are checked against what the computer reported for the agent (capabilities.agents; tuningProblem): long context needs a model the agent offers it for (or a fixed 1M window; on Claude Code a named model), fast mode needs Claude Code or Codex and a model that has it, and a thinking level kept from the session must suit a newly named model. A setting that cannot be honoured is refused once, with what to do instead ("GitHub Copilot on Mira's laptop does not offer long context for GPT-5 mini. This session used long context, so it was kept. Run with the default window (context: default), or choose another model."). Nothing is swapped quietly. A computer that did not report its agents is not refused on a guess.

When settings were asked for, the agent's instructions end with "Ensemble already started you with …. Do not check these settings yourself." (appliedLine), so no worker stops to check its own model.

8.11 The watchdog and the agent's questions

checkSessions(app) (node/watchdog.ts; startWatchdog, stopWatchdog) applies code rules to the runs of nodes' sessions. Each finding gives the run's node one attention turn (reason: "attention") with a note, never a chat message. The note says that agents recover from errors by themselves: the node usually leaves it, steers only when the same failure keeps happening and it knows the fix, and never stops a run its owner asked for. An attention turn about a plan step's run runs in that step's lane, and the step's own node steering or stopping its own run is the step going on, not work from outside it.

Rule Raised when Setting
No progress A working run shows no events, output or step changes for this long (the computer's heartbeat does not count) ENSEMBLE_NODE_WATCHDOG_STALL_MS, 15 minutes
The same error One failure (a failing command, tool call or engine restart, read from its JSON error or message, else its first real line) this many times in a working run, and as its latest failures this many in a row. Looking around is not failing: a read, view, list or search that finds nothing, grep or find with exit 1, and "Path does not exist" or "view_range out of bounds" never count (explorationMiss) ENSEMBLE_NODE_WATCHDOG_REPEATS, 5; ENSEMBLE_NODE_WATCHDOG_STREAK, 4
Tokens without changes A code run spends this many tokens without a file-changing tool call or a changed file ENSEMBLE_NODE_WATCHDOG_TOKENS, 2,000,000
Files outside the folder A run, going or ended in the last 30 minutes, changed files outside its folder —
An agent's question A question of fact (below) waits for the owner —

Each condition reaches the node once per run (node_runs.watchdog, taken with a compare-and-set). A paused node hears nothing; the finding stays open for when it is back. The check runs every ENSEMBLE_NODE_WATCHDOG_MS (a minute).

The agent's questions. A run's ask_person question goes to its node first, unless the owner turned that off (autonomy.answerAgentQuestions: false). The node may answer with node_answer_question {decision, answer, quote, source} only from a fact quoted word for word (letter case and spacing aside) from its source: owner (the owner's own message that started the work), mission (its goal and requirements), step (the step's brief) or constraints (the mission's). The node's own words to the agent are not a source. A question with options takes one of them. The answer is recorded on the request (detail.answeredBy {nodeId, handle, source, quote}), on the run ("Answered by @node from its owner's message: …") and on the session, and goes to the agent as the owner's would. Permissions, widening the scope, security, cost and anything destructive (questionRefusal in node/session-questions.ts) never reach the node and are refused if it tries; they wait for the owner on the run's card, as does every question it leaves. The first answer wins, from the node or the owner, including the owner's reply in the chat (§8.6).

8.12 Results and evidence

A coding agent reports its own result, apart from its closing words: report_result on the run's MCP server, or .ensemble/result.json when it has no MCP (30 §9). The report is a StepReport (node/report.ts): outcome, summary, checks with verdicts, the commit or content hash it produced, evidence, and why it is blocked. The computer checks it and keeps it on the job; complete brings it to the web, where it is checked again and kept on the job (workspace_jobs.report) for the node and its plan to read.

Evidence. The agent attaches what its owner should see (attach_evidence): screenshots, videos, traces, reports, logs and other files; what its browser and capture tools write is evidence already (§8.16). The computer checks each file and keeps a copy in the run's evidence folder, outside the repository, then uploads it (25 §4.11). Each upload lists the file on the run's card: data.evidence is [{id, kind, name, mime, size, caption?, requirementId?, createdAt, local?, held?, holdReason?, poster?, preview?, width?, height?, durationMs?}] (refreshRunCard in evidence/store.ts, from the node_runs row of the job); a video names its poster and small preview image (evidence ids of their own, not listed apart). GET /api/node/evidence?jobId= is the full list (?nodeId= a node's own captures) and GET /api/node/evidence/:id the file (32 §7).

  • Owner only. Only the account whose computer ran the job reads the files, never other people in the room; the run card is already owner-only where others read (§8.1).
  • Kept on the computer. With ensemble evidence off on that computer, the files stay there. The card lists them with local: true, for the website to show as "on this computer, not uploaded"; they cannot be opened from the web (409).
  • Held for the owner's preview. A capture the computer could not clear is listed with held: true and holdReason (and local: true); only its owner opens it, from the computer, until they release or discard it (§8.16).
  • Never attached: links, hard links, key, certificate and .env files, shell histories, Ensemble's own data, the browser profile and any file holding a saved secret value. Text is redacted on the computer and again on the web.

8.13 The node's own tools on the computer

A node does what it can on its owner's computer itself, without a coding agent: it runs commands, downloads and installs software, reads and writes files and reports, takes terminal screenshots, shares files into the chat, reads git, calls a development server, and browses and captures (§8.16) (node/device-tools.ts, deviceTools(scope); tools in §6). Writing or changing code in a repository goes to an agent session (§8.1).

  • Where. Only in a turn that only the owner reads and that the owner started (a DM, a crew, their private thread). Never in a turn someone else started, never where other people read, and for a helper only with its parent's computer grant, in the folders it named (§6.1). The output stays in that turn.
  • Which computer. The one named (computer), else the owner's computer seen online most recently. It must report capabilities.nodeTools 1 (2 for the node's own folder, terminal screenshots, sudo and sharing files; 3 to stop a background command; capabilities.nodeFiles 1 for sharing a folder and saving an attachment there); otherwise the node is told "Update Ensemble on <computer>".
  • How. Each call goes to the computer as node.tool.request and is judged and run there (25 §4.10): the guard and the OS sandbox as for an agent's commands, with keys, Ensemble's data, the run secrets and the browser profile denied. The browser and capture tools go the same way (§8.16); they need capabilities.browser 1 there and a browser for Ensemble ("run ensemble capture setup" otherwise).
  • The node's own folder. A call that names no folder works in <workspace>/nodes/<handle> on that computer, made on first use (mode 0700, "@handle's folder"): downloads, notes, reports and scratch work go there without asking. Ensemble's run folders (<workspace>/runs/<name>) and every node's folder count as its own too: no card asks about them (the floor still applies).

Who decides is the computer, in this order:

  1. The hard floor. Keys, sign-ins, Ensemble's own data and code, system folders and links are refused, whatever the owner chose; so are the dangerous commands below, for every command a node or a coding agent starts (device-tools/dangerous.ts, checked on the computer and again on the web, and by the judge for agents' shell commands).
  2. Allowed without asking: the node's own folder and Ensemble's run and node folders (where the sandbox is weak, their commands ask once per mission), and public web pages (a public name that resolves only to public addresses). The owner can have public pages ask too, with Ask before a node opens a new website on Node (settings.node.askBeforeBrowsing, sent with each call) or ensemble approvals websites ask on that computer. The computer's own and its network's addresses (a development server, the router, a NAS) always ask the first time.
  3. The owner's answers kept on that computer, for a folder or a web address, for one mission or always.
  4. Unattended (the node's run profile runs unattended and Work on your computer is not Ask me first): the call runs inside the sandbox.
  5. Attended: the first use of any other folder (or address) asks the owner once, on a card in the chat where the node works (data.type: "computer", §8.6), with Once, This mission and Always for this folder (or for this address). The node's call waits for the answer and goes on by itself. This mission covers the plan the turn works for, else this conversation. Where the computer's sandbox is weak, Always is not offered for commands. The answers count only while the computer accepts approvals from the web; with that off, a call without an answer kept there is refused.

Commands. computer_run takes one program and its arguments (a shell line through bash -c), up to 900 seconds. Its output is redacted and at most 64 KB comes back; computer_run_output reads the rest (kept 6 hours, at most 1 MB per command). A command still going after a minute, or started with background, runs on, and its end wakes the node once (enqueueTurn, reason tool) in the same conversation, with a hint for a failure it knows (node/sign-in-hints.ts). computer_run_stop stops one: the computer (capabilities.nodeTools 3) ends it and what it started, and its end wakes no one; an older computer cannot, and the node is told it ends at its time limit. A sign-in (az login, gh auth login…) runs in the background for 15 minutes, and its code or page goes to the owner (§8.16.4). secrets puts named saved secrets into that command's environment only; the node never sees a value. git push and git fetch are refused (they need the owner's sign-in: an agent session does that). sudo is not a program a node runs this way: it uses computer_sudo.

Terminal screenshots. computer_terminal_screenshot runs a command in a pseudo-terminal (120 × 32 by default) for seconds (3 by default), types keys if given ({F5}, q, {Enter}), and captures its screen as a PNG with the screen's text: htop, top, a build's output. Sandboxed like computer_run, it may list the computer's processes where that is safe (hostPids, 24). The picture shows with the node's reply (§8.14).

As the administrator. computer_sudo {command, reason} puts the exact command on an owner-only card in the chat (data.type: "sudo", node/sudo.ts): the command, the node's reason, the computer, and a password box. The command's dangerous forms are refused before any card, as the administrator (below). The owner runs it with their password, runs it without one (for passwordless sudo), or declines. The browser seals the password for that computer's key (purpose sudo, the card as the decision, the command's SHA-256 as the name; the same sealed box as secrets, §8.7.2), the web relays the box once from memory, and only that computer opens it, for that command, once. It is typed into sudo -S -k on its input, never stored, logged, put in the ledger or shown to a model, and removed from the output. A password sudo does not take puts the card back for another try, at most 3 tries. The node's call waits for the answer and gets the output; a turn that parked hears how it went. The card closes after 30 minutes unanswered. GET /api/node/sudo/:messageId gives the page the command and the computer's key; POST /api/node/sudo/:messageId {action: "run", sealed?} | {action: "deny"} answers once.

Sharing a file or a folder. node_share_file {path, folder?, caption?} brings a file from the computer into the chat: any kind (Markdown, text and logs, code, CSV and JSON, PDFs, pictures, videos, Office files, archives), checked like an attached file (no link, no key, certificate, .env, history or credential file, nothing in a key folder or git's, not Ensemble's data or another account's, no saved secret value) and kept as the node's evidence. An archive is also read for what it holds: a key or .env inside, or a saved secret value in its first 256 MB, refuses it. A folder (path naming it, . for the whole folder) is zipped on the computer into <folder>.zip, each file checked the same way and its text redacted, leaving out links, what the floor refuses and dependency and cache folders (node_modules, .venv, __pycache__, .next, .cache, …); the answer names what was left out. The cap is 100 MB per file, and per zipped folder: the largest evidence already was (videos), one upload request and one read in memory on each side, the same as the transfer limit for a task's attachments and Files' own cap on a self-hosted server (hosted Files keeps 25 MB, so hub_save_file refuses a larger one there). It shows with the node's reply (§8.14). Sharing from a folder other than the node's own asks once for that folder (read). A shared file is never held for the owner's preview: preview holds are for captures, whose screen may show what the node did not choose (§8.16).

Receiving a file. node_save_attachment {attachment, folder?, path?, overwrite?} puts a file the owner attached in the chat on the computer, to work with it there: by its name as attached (the newest message with it) or its id, at most 100 MB, at path in the folder (default its own name; a path ending in / puts it in that subfolder), the node's own folder unless one is named (another asks once, like a write). The web gives the call a download id (node/attachments.ts, Redis, 15 minutes) and the computer fetches the file once from GET /api/devices/self/node-files/:downloadId, only that computer; it must be the size and SHA-256 named, it is written under the same rules as computer_files_write, and a file already there is replaced only with overwrite. The node's transcript names the tool beside the owner's attached files ([attached “sales.csv”; onto the computer: node_save_attachment]) where only the owner reads. Into Ensemble Files: hub_save_file {attachment} (Saving files, §6). The ledger has node.file_download on the web and node.tool.files.save on the computer.

Dangerous commands. Refused for every command, whatever anyone answered; the node is told why and that the owner can run it themselves in their own terminal. Installing software, apt, services, Docker and the like are not on the list: they are the owner's choice, on the card.

What For example Why
Recursive delete of the whole disk, the home folder or a system folder rm -rf /, rm -rf /*, rm -rf ~, rm -rf $HOME, rm -rf /home It deletes the operating system or every file of the owner, beyond recovery.
Formatting, partitioning or wiping a disk mkfs.ext4 /dev/sda1, mkfs -t xfs /dev/nvme0n1p2, wipefs -a /dev/sda, fdisk /dev/sda, parted /dev/sda mklabel gpt It erases a disk or its partition table.
Writing straight to a disk device dd if=/dev/zero of=/dev/sda, dd if=image.iso of=/dev/nvme0n1 bs=4M, cat file > /dev/sda, shred /dev/sda, cp image.img /dev/mmcblk0 Raw writes to a disk destroy its file systems.
Shutting down, rebooting, suspending or switching the system's run level shutdown -h now, reboot, poweroff, halt, systemctl reboot It cuts off the owner's work and Ensemble itself; the owner does that, not a node.
A fork bomb :(){ :|:& };:, bomb(){ bomb|bomb& }; bomb It makes processes until the computer stops responding.
Changing the owner or permissions of the whole disk, the home folder or a system folder chmod -R 777 /, chmod -R 000 /etc, chown -R nobody /, chown -R user:user /usr, chgrp -R users / Recursive permission changes there break the system and sign-in, and are hard to undo.
Changing accounts, passwords or who may use sudo passwd, passwd root, chpasswd, userdel akash, deluser akash It can lock the owner out of their own computer.
Changing how the computer boots or mounts its disks grub-install /dev/sda, efibootmgr -B -b 0001, rm /boot/vmlinuz-6.8.0-45-generic, update-initramfs -d -k all, echo x > /etc/fstab A mistake leaves the computer unbootable.
Killing every process, or the system's own kill -9 -1, kill -9 1, killall5, pkill -9 -u root, pkill systemd It ends the session, the desktop or the system.
Cutting the computer off the network or remote access iptables -P INPUT DROP, nft flush ruleset, ufw default deny outgoing, ip link set eth0 down, ifconfig wlan0 down The owner (and Ensemble) can lose access to the computer.
Removing a package the system needs, or forcing a removal apt remove systemd, apt-get purge -y sudo, apt autoremove --purge libc6, dpkg --purge --force-all bash, dnf remove glibc Removing the package manager, the C library, init, the shell, the kernel or the desktop breaks the system.
Moving, emptying or overwriting the system's own folders and files mv /usr /usr.old, mv /etc /tmp, mv ~ /tmp/x, > /etc/hosts, cp /dev/null /etc/os-release The system stops working.
Deleting every scheduled job crontab -r, crontab -r -u root crontab -r removes all of a user's scheduled jobs at once, with no undo.
An open administrator shell (as the administrator) sudo -i, sudo -s, sudo su, sudo su -, sudo bash Approve one exact command instead, so the owner sees what runs as the administrator.
Running a script from the internet as the administrator, unseen (as the administrator) curl -fsSL https://example.com/install.sh | sudo bash, wget -qO- https://x.sh | sh, curl https://x | sudo sh -s -- --yes, bash <(curl -s https://x) Download the script, show your owner what it does, then run the file.
Reading keys, password hashes or another account's files as the administrator (as the administrator) cat /etc/shadow, cp ~/.ssh/id_ed25519 /tmp, tar czf x.tgz /root, cat /home/akash/.aws/credentials, ls /root Keys and password hashes never leave their files through a node.

The check reads a command as a shell would, roughly: it splits a line on ;, &&, ||, |, &, new lines, $(…) and backticks, drops wrappers (sudo, env, nohup, timeout and others), and reads bash -c "…" and eval again. It is a floor, not a sandbox: commands also run in the OS sandbox, and every administrator command needs the owner's approval of its exact text.

Ledger. Every call writes node.device_tool.<tool> on the web and node.tool.* on the computer, with the folder and the program, operation or address, never the output; for sudo only the command's SHA-256, the folder and the exit code.

8.14 Media in the conversation

A node's message can carry screenshots, videos and files its owner's runs attached or the node captured itself (data.media: [{evidenceId, kind, name, mime, caption, size, posterId?, width?, height?, durationMs?}], at most 8; node/media.ts); a video names its poster image, which opens for whoever may open the video:

  • What the node captured. Screenshots, videos, terminal screenshots and shared files the node's own tools kept in a turn show with its reply by themselves, the latest 8 in the order taken (rememberCaptured, takeTurnMedia). The tool's answer says so ("This screenshot will appear with your reply."), and says plainly when nothing came back, so a node never claims a picture it does not have.
  • Chosen by the node. node_show picks what shows instead: among several captures, older evidence, or what a run attached (a run's end note lists its files by id). Evidence of someone else, kept on the computer (upload off), or held for the owner's preview is refused with why. Files with nothing to say are posted on their own.
  • A plan's report. The lead's report message brings the media of the steps that were done or passed: the files the worker named in its report or checks first, then the screenshots and videos its runs attached; video first, at most 6. A failed or held step's media is not shown by itself: what reaches the chat was checked. Where a step's requirement asks for a screenshot or video (evidence in the mission contract, §6.3), only the files a passing check of its report names show (a capture_check: the app alive with its window title, the image not blank, the expected text on it; checkedMedia), never one a failing or skipped check names. A held capture or a video's poster alone never shows.
  • Who can open a file. GET /api/node/evidence/:id?message=<id>: its owner, and anyone who may read the message that shows it (a person of the room who can see the message and, in a room with other people, had joined when it was posted). A held file only its owner. An approved draft keeps its files and record names, so sharing a result in a room is the owner's act (§5).
  • How each shows. kind is the evidence kind (screenshot, image, video, report, log, trace or file; a zipped folder is a file named <folder>.zip), name the file's real name and mime the type sniffed from its bytes (32 §7; the name only chooses among text types, Office types and the like). Node decides by mime and the name: pictures (never SVG) and videos in place, Markdown, text, code, CSV and JSON as a preview, everything else (PDFs, archives, Office files, programs) as a file with its name and size that downloads. The route serves pictures, video and audio inline, PDFs and plain text inline only with ?view=1, and everything else, every active format (HTML, SVG, XML, scripts) included, as a download with nosniff and a sandbox CSP.

8.15 Parallel work in one repository

The mission chooses how its builds share its folder (constraints.parallel, node/parallel.ts). Ensemble never pushes, merges or opens a pull request beyond the mission's publish and branch, and never refuses work because something is in use: conflicting work waits.

parallel Builds Their checks Then
serial (the default) In the folder's one checkout, one step at a time In the checkout, in turn Nothing more
worktrees Each in its own git worktree of the folder, on a local branch <branch>-<mission>/<step> (ensemble/<mission>/<step> when the mission names no branch; git cannot keep <branch>/… beside <branch>), never pushed In the build's worktree, against the commit it reported The integration step merges the branches into the folder's current branch (integrate: rebase replays them; patch, or publish: none, which leaves nothing committed, applies their changes); a check after it tests the integrated commit
snapshots Each in its own detached worktree (no branch) In the build's copy The integration step applies each one's changes as a patch
  • Claims. Before a dispatched step starts (§6.2), it takes its place on the computer (placeStep in node/placement.ts, rows in resource_claims, node/claims.ts): the folder's checkout for serial work, an integration step, its check and writing; or its build's worktree and branch; and, when the mission builds in parallel, a free development-server port (4300–4399 on that computer), which its packet names ("if you start a development server, use port N"). Keys name the computer (<device>:<folder>). A claim blocks only that resource. A step that finds its place taken stays working with "Waiting for @builder's step to finish with live-test." and asks nobody; when the holder lets go, the first in line holds it at once and is woken through the outbox. Claims end in the transaction that ends their step, or their mission when it ends or stops; a claim of an ended step or a deleted mission is let go the next time someone asks for its resource.
  • The integration step is a build step with integrates (the builds it brings in), after those builds and their checks, with a check after it. The lead adds it in node_plan or node_plan_revise; validation refuses a worktrees or snapshots plan with two or more builds and none (§6.3). Code writes its part of the packet (integrationPart): each build's branch or snapshot and commit, how its check went, how to combine them as the mission says, the recorded checks to run again, and to report the integrated commit (with publish: none, the content hash, left uncommitted). It works in the folder's checkout, in a session of its own.
  • What the computer is told. Every run of a plan step carries context.mission {id, step, worktree?, port?, integrates?}, read from the step's claims (missionContext), so the packet's run, a repair, the node's own session call and an approved card all work in the same place. The claim sends it as mission (25 §4.12). A worktree run waits in the queue only for runs in that worktree (key worktree:<name> instead of the folder's label:; worktreeKeys in workspace/assign.ts), so builds run at once. A computer that does not report capabilities.worktrees builds in the checkout, one step at a time, and the packet says so ("Ensemble on Laptop is too old to make worktrees…").
  • Verification names its candidate. A check is told the commit (or content hash) it tests; the check of an integration step tests the integrated commit, and a retest after a repair the repaired one. A check that passed, after which another step reported a different commit or content in the same place (the checkout, or that worktree), is stale: the card shows staleAfter {step, handle, candidate, at, tested} (tested: what the pass was for) and the lead's report says the pass does not cover the later change (staleChecks). Nothing is held because of it.
  • One mission's runs that meet in a folder outside their own on one computer take turns there without a Needs me card (24 §3); runs of other missions, or started by hand, are asked as before.

Browser profiles and tabs take the same claims (claimResource, waitForResource, releaseResource, kinds browser_profile and browser_tab, keyed by computer): a second holder waits in line until the first lets go.

8.16 Browsing and capture

On a paired computer a node and the coding agents it runs browse and capture through the computer's browser broker and capture service (25 §10), which the desktop app and the CLI's runner start with Ensemble (never the hosted server). Both callers have the same tools, run by the same tool layer under the same rules on the computer:

Tools What they do
browser_open, browser_tabs, browser_close, browser_snapshot, browser_click, browser_type, browser_press, browser_wait Browse in the computer's own Ensemble browser, with one persistent profile per owner (sign-ins stay) or a private tab; read pages as an accessibility tree and act on its references. A window a page opens (a payment or sign-in popup) is a tab of the same work: the action that opened it says so ("It opened a new window t16 (Komoju: Payment, komoju.com): use tab t16."), browser_tabs lists it with popup: true and openedBy, and it shares its opener's lease (using either keeps both; neither closes while the other is held for the owner)
browser_screenshot, browser_record, browser_console, browser_network_errors, browser_upload, browser_download Evidence from a tab: masked screenshots, a video or trace, its errors; files in and out of the run's evidence folder
capture_screenshot, capture_video Screenshots and video of a web page or localhost app (at any viewport or emulated device), an Electron app, any other desktop app on a private virtual display, or a terminal transcript. mode: "frames" records an animation frame by frame, smooth whatever the load
capture_app_start, capture_app_stop, capture_apps Start an app to capture on a private virtual display (Linux), never on the owner's screen
capture_check The checks before sending media: the app runs and has its window, the image is not blank, the page shows the expected text. Each is a Check for the step's report (§6.2)
capture_status What this computer can capture, and what ensemble capture setup would add
browser_login_needed, browser_login_check Hand a tab to the owner to sign in or to pass a human check (purpose); it pauses until it is done, which the computer sees by itself. The check asks now (§8.16.1)
browser_view "Show me this page" in one call: open, close banners, set the place, a screenshot of what focus names ("the stock chart"), the page's text; a site that refuses the browser is shown read-only from a clean browser
browser_shop Prices across stores in one call (default Amazon, Walmart, Target, Best Buy, Costco), with the delivery place set where the store takes it ("Wisconsin" → ZIP 53703 on Amazon and Target): each store's top offers, the place its page shows, a screenshot of its results (none of a human check or an empty page) and its status (ok, human_check, blocked, no_results, error)
browser_fetch A file (a paper's PDF, an image, a CSV) downloaded with the browser's sign-ins into the node's evidence; hub_save_file {evidence} then puts it in Ensemble Files
browser_extract A page as Markdown (tables as tables) with its products and prices
browser_set_location Where the browser shops from: Amazon's "Deliver to" ZIP, Best Buy's US site, the browser's location
  • A node has them as device tools (node/device-tools.ts), where its computer tools are offered (§8.13: only where the owner reads alone and started the turn; a helper with a computer grant, starting apps only in its folders). Each call is a node.tool.request with tool: browser | capture to the computer the node names, else the one online. It runs for the node's one tool operation, node:<nodeId>:own, so its tabs and apps last across its conversations, crews and background work; each use renews their leases (tabs 10 minutes, apps 30). Its evidence counts against the operation's limit over the last 24 hours, and files older than 30 days leave its folder. Public pages open without asking; the computer's own and its network's addresses ask once each, and starting an app once per folder (§8.13); the node's run profile with the internet off opens only the computer's own addresses.
  • What comes with the reply. Screenshots of browser_view, browser_screenshot and browser_shop (one per store) come with the node's reply by themselves. A file from browser_fetch or browser_download is a document to keep, not a picture: nothing shows by itself, and the node is told to call hub_save_file with its evidence id to put it in Ensemble Files (or node_show to show it). browser_shop's stores name their screenshot by evidence id. Page text (a snapshot, Markdown, a store's text) is in the tool's summary and left out of the result the node reads. The computer must report capabilities.browser: 2 for the web tools (25 §10.1); browser_shop gets three minutes, the other web tools two.
  • A coding agent has them on its run's MCP server, for its job (evidence/capture-tools.ts, 30 §9); a run with the internet off browses only the computer's own addresses, and its apps start in its folder, sandboxed as the run is. When the run ends, its recordings are finished and kept, and its tabs, apps and displays end.
  • What is captured is evidence at once, with no attach step: a run's (its job) or the node's own (no job: job_evidence.node_id and operation), with a video's poster and small preview beside it. The answer names each file's id, for node_show and for a report's checks. Files land in the work's evidence folder on the computer: images, MP4 or WebM videos, traces and transcripts. Upload follows ensemble evidence on|off as for attached files.
  • Confirm before sending. A step's packet (§6.2) tells its agent to check each capture with capture_check (the app still runs with its window title, the image is not blank, the page shows the expected text) and to put those checks, naming the file, in report_result's checks; only checked captures reach the owner (§8.14), and a blank or wrong one is a failed check, which starts the repair loop. Then a model that reads pictures judges them (node/vision.ts, run by finishStep before a report counts). It looks at each uploaded picture a passing check names, a video by its poster, at most four, against the requirements that asked for a screenshot or video and their acceptance criteria. Its verdict is one more check on the report, Vision: ‹file› shows what was asked. On a verification a picture that does not show it fails the check, so the build is repaired and checked again, and the repair is told what the picture showed. On any other step it only keeps that picture from being shown. The judge is ENSEMBLE_NODE_VISION_MODEL (off for none), else the first offered of gpt-6.1-sol, gpt-6-luna, gpt-5-mini and gpt-4.1-mini, at low thinking, on the owner's included credits. A held or local file is not judged (it is not on the server). When no model can judge (none offered, credits used up, the call fails), the report stands as the worker made it.

Held captures. A capture the computer cannot clear (it shows a saved secret or something key-shaped, it is a desktop app's window, or the owner asked to see captures of that site first) comes back held, with the reason. It stays on the computer: only its description reaches the web (held, holdReason), it is never sent to a model and never shown in a chat, and node_show refuses it. Each held capture appears on one owner-only approval card where its work is followed: for a run, by the run's node in the run's room; for the node's own, in its conversation (data.type: "media_preview", status, evidence (the files, as on a run card), reasons, jobId or operation, nodeCapture, planId?, planStep?). Later held files of the same work join the card while it is pending. The card never keeps a plan step waiting and does not expire with it.

  • Preview. Opening the file (GET /api/node/evidence/:id) fetches it from the computer for its owner (evidence.request, action preview), kept in Redis for five minutes with Cache-Control: no-store. An offline computer, or one that does not answer in time, is a 504; a file too large to preview a 413; a computer that no longer has it, or is no longer paired, a 404 or 409.
  • Approve (POST /api/node/approvals/:messageId {decision: "approve"}, or POST /api/node/evidence/:id/release) asks the computer to release each file: it uploads it, with its poster and preview, and it is ordinary evidence from then on, for node_show and a plan's report. The card becomes released, and a node that captured it is woken to show it. A computer that is offline, or keeps its files (upload off), leaves the card pending with why.
  • Discard deletes the files on the computer and on the web; the card becomes discarded.
  • One file at a time (POST /api/node/evidence/:id/release): each answer is kept on its card as it is given (data.answers {evidenceId: released | discarded}), and the card settles when none of its files is held any more, with every answer it was given in result {released, discarded}.

Sites the owner marks never captured, and tabs in a sign-in, are not captured at all; the owner keeps both site lists on the computer (ensemble capture sites, 26).

Never Ensemble itself. A node acts in Ensemble as its owner through its Ensemble tools, so its browser and capture calls, browser_shop stores and web_task (its start and every goto) never open Ensemble's own web app: refuseEnsembleApp (node/device-tools.ts) refuses them on the server, before anything reaches the computer, with "… is Ensemble itself, not a website to open or sign in to. Use your Ensemble tools: …" naming the board, task and page tools. The origins (lib/ensemble-origins.ts): app., test., api., api-test., node. and node-test.ensemblework.com, and the server's HUB_WEB_ORIGIN and ENSEMBLE_NODE_WEB_ORIGIN unless they are the computer's own addresses (a Hub in development on localhost). An agent run is told the same by its MCP server (39 §4); testing Ensemble's web app as a product is a run's work.

Live view. While a run's agent uses a tab, its computer sends a small JPEG of it about every 3 seconds (POST /api/devices/self/jobs/:id/live under the job's lease, protocol 7; devices/live.ts). The server keeps only the latest, in Redis for five minutes (one a second at most), and sends node.live {jobId, at, paused?} on its owner's Node stream; GET /api/node/live/:jobId answers the latest picture (image/jpeg, no-store, headers X-Ensemble-Live-At, -Tab, -Url and -Title URI-encoded, -Width, -Height) or 204 when there is none or the view is paused (X-Ensemble-Live-Paused: sensitive (a sign-in), controlled (its owner is taking it over from the web), private_site or secret_on_screen). Only the account whose computer runs the job; nothing is stored in the database. A node's own browsing has no such picture: its owner watches it with a live view (§8.17). The X-Ensemble-Live-* headers are exposed to the Node site's origin (CORS exposedHeaders), so it can read them.

Sites that check for robots. The computer's browser presents itself as that computer's Chrome: no automation flags, navigator.webdriver false, a user agent without HeadlessChrome, the computer's language and time zone (25 §10). When a page is still a human check (a captcha, "Robot or human?", a Cloudflare wait, an access-denied page), browser_open, browser_snapshot, browser_view and browser_shop say so (humanCheck, a store's human_check), and the node asks its owner to pass it with browser_login_needed (purpose: "human_check", §8.16.1) or uses another source; prices and facts come from hub_web_search first (§6). A site that refuses the browser outright (an HTTP/2 error, a 403 or 429 page, its search service refusing) is read once more in a clean browser, read-only, by browser_view, browser_extract and browser_shop.

8.16.1 Signing in

A page that needs the owner's account (a sign-in form, a session that ended), or a person to pass a human check (a captcha, "Robot or human?"), is handed to the owner; no agent, node or model ever types or sees a password or code, or tries a check (design B11, the plan's §10.2).

Raised. The broker raises a sign-in for a tab (logins/requests.ts) in two ways:

Way When
Its heuristics (logins/detect.ts), after browser_open, a click, a key press or a submitted browser_type The page has one password field (not a new-password form), is a sign-in address (/login, /signin, /auth, /session, /oauth, …) with a user name or code field, answered 401, or was sent to a known identity provider (Google, Microsoft, Apple, Okta, Auth0, OneLogin, GitHub's sign-in, …). Only in the owner's shared profile and on another site: this computer's own addresses (usually the app a run builds) and private tabs (clean contexts for testing) only get a hint, and a site the owner already answered for this work is not asked again
The work itself: browser_login_needed {tab, origin?, purpose?, signedIn?: {urlPattern | selector}, reason?} A node or a coding agent knows it needs the owner's account (purpose: "sign_in", the default) or meets a human check ("human_check"). A page the computer sees is a human check, with no sign-in form on it, is raised as one whatever was asked. signedIn says how to tell a sign-in worked; by default, the tab is on the site with no password or code field left

At once the tab is sensitive: no snapshot, screenshot, recording, trace, console log, live view or agent action until it ends; its lease is kept while it waits (two hours at most on the computer). A window already on the computer's screen comes to the front. The tool answers that the tab waits for the owner, never with a value.

Held until answered. The broker holds a waiting sign-in's tab (BrowserBroker.holdTab): no lease, janitor or idle timer closes it. When its page or its whole browser closes without Ensemble closing it (Chromium exits, or its window on the computer's screen is closed), the tab opens again under the same id at the page it showed, without a window and still paused, and the sign-in keeps waiting (at most three times in ten minutes; a page that keeps closing itself is let go). A sign-in ends only when the owner answers or declines, it expires, its work ends (the work closes the tab, or the work stops), or the computer stops taking work from the web. A remote view, an I'm done or a typed answer that comes while the tab is opening again waits for it.

Seen done. The computer watches a waiting sign-in by itself: its page every two seconds and after each load, in the node's tab or in the clean browser the owner uses (25 §10.1). A sign-in counts only by a change (the page asked to sign in, then it is the site with no sign-in form, not a human check, and the site's session changed); a human check, when its challenge is gone and the page has loaded. Then it ends at once, the tab is given back, and the computer tells the server (POST /api/devices/self/logins/:loginId/state, capabilities.logins: 2), which updates the card and wakes the node. Nobody has to press anything. When the owner says in the chat that they did it, the node checks with browser_login_check {tab}.

The clean browser. Signing in from Node (Open the browser) or in a window on the computer happens in a clean browser: a Chromium the computer drives only with real input and the screen, never a page script, in the profile's own sign-in folder. Google lets the owner sign in there (it refuses the node's own browser: "This browser or app may not be secure"), Cloudflare's checkbox works, and two-step codes, approvals on the phone and creating an account are done as in any browser. It opens at the node tab's page, follows a "Sign in with Google" popup and back, and when the sign-in is seen done its cookies (the site's and the identity providers', session ones too) and the site's local storage move into the node's browser, whose tab then opens the signed-in page and must pass the check itself. It stays signed in to the identity providers for the next sign-in.

The card. One owner-only approval card, in the node's conversation and on Node's Needs me, and for a run's sign-in in the Hub's too (data.type: "login"): "@node needs you to sign in to portal.azure.com." or, for a human check the browser found (a captcha, "Robot or human?"), "@node needs you to pass a human check on walmart.com." (purpose: sign_in or human_check, from LoginRequired.purpose; absent is a sign-in). It has status (pending, working while the computer acts, needs_code while the site asks for a code, signed_in, passed for a human check, declined, expired, ended), codeHint (what the site asks, with needs_code), loginId, deviceId, computer, origin, tab, reasons, saved (a login is saved on that computer for this one site), display (the computer has a screen), takeover, expiresAt (when it stops waiting: two hours after it was raised, a routine's sooner, a command's when the command's time runs out; never under 15 minutes for a routine), jobId (a run's) or operation (a node's), source and command (cli: a command opened the page, §8.16.4), private, plan (the step waits for it), error, note, via. A node's browser call raises it from its answer (NodeCaptureAnswer.login); a run's computer raises it at POST /api/devices/self/jobs/:id/logins under the job's lease (protocol 8), and it appears where the run is followed, by the run's node. A run no node follows asks on Needs me with a question instead (use the saved login, signed in on this computer, not now). A routine's card expires after 30 minutes, as its other cards do (ENSEMBLE_BACKGROUND_DECISION_MINUTES, never under 15 minutes for a sign-in), and its expiresAt says so; then, or when its plan ends, the computer is told and frees the tab. A run's card still waiting ends with its run (status: "ended").

The owner answers with POST /api/node/logins/:messageId, relayed to the computer as login.resolve (protocol 8):

Answer What happens
Use saved login ({action: "saved"}, when saved) The broker fills the login saved on that computer (user name, password, then a one-time code from a saved seed, a step at a time as the site asks) and presses Enter. Not for a human check
Sign in on the computer ({action: "local"}, when display) The clean browser opens in a window on the computer's screen at the tab's page. The owner signs in there; the computer sees it done, moves the session into the tab and closes the window. I'm done ({action: "done"}) checks at once
Type it here ({action: "typed", sealed}) User name, password and an optional one-time code, typed on the card, sealed in the browser for the computer's key as B12 secrets are (§8.7.2): purpose login, the card as decisionId, the sign-in's loginId as name, the site inside the box. The computer opens it once, checks it as a secret box and that the site matches, and fills the page; it is signed in only when the page moved on and passes the check. When the site then asks for a one-time code, the answer is needs_code with what it asks ("Enter the 6-digit code from your authenticator app."), and the owner sends the code the same way ({action: "code", sealed}, {origin, code} inside); when it waits for an approval on the owner's phone, the answer says so. Save on ‹computer› keeps it as a saved login there (https sites, or the computer's own http addresses), never on the web. Not for a human check
The code ({action: "code", sealed}, while needs_code) The code the site asks for after a typed login (codeHint says which), sealed and bound exactly as a typed login, {origin, code} inside. A computer without logins 2 is asked to update
Open the browser (when takeover) The owner signs in, or passes the check, from Node in the clean browser, end to end (§8.16.2); it ends by itself when it is seen done. The Hub's Needs me links to Node for it
Done in the chat ({action: "check"}) The same as I'm done, asked when the owner says in the chat that they finished (below). A computer without logins 2 is sent done, which checks the same way
Not now ({action: "decline"}, or Discard) The sign-in ends: the tab is cleared (about:blank) and given back. The node hears it and finishes its step without it or calls node_step_blocked; a step that cannot get a turn ends blocked (login_required). A run's agent is told the same in its tool call

Then the broker checks the signed-in condition (a human-check page is never signed in). Signed in, or the check passed: the tab is the work's again, the card says how (via: saved, typed, code, computer, window, takeover, check, chat), and the same work goes on: a node is woken ("signed in to ‹site›; tab tN is yours again"), a run's agent gets that answer to the call that waited. A site that asks for a code (needs_code, with what it asks): the card asks the owner for it. Not signed in (a wrong password, a site that wants more): the card stays pending with why, for another try.

"Done" in the chat (node/login-check.ts). Owners often say "done", "I signed in", "logged in, try now" or "passed it" instead of pressing I'm done. Before a conversation turn that a message of the owner's starts (checkLoginOnDone, the first thing runNodeTurn does), a short message that says they finished (and no "not yet") checks this node's newest waiting sign-in card in that thread ({action: "check"}). The turn's note says how it went, and is kept on the turn's row: "Signed in to portal.azure.com; carry on." with the tab given back (the node is not woken a second time for it), "Passed the human check on walmart.com; carry on.", that the site now asks for a code (the card asks for it), or "Still not signed in to portal.azure.com: ‹why›; the card is still waiting." A step's or a run's sign-in is woken as when the card is answered. The node never reads the paused tab itself. The sign-in stays in the owner's browser profile, so the next task on that site is signed in already. GET /api/node/logins/:messageId gives the page the computer, its pinned key for typed answers (route.mode sealed, pair_again or unsupported) and what it offers.

Saved logins are kept on the computer only, apart from run secrets: logins.json in Ensemble's data folder, mode 0600, every value encrypted with the runtime's local key (as seal-key.json is; Ensemble keeps no key in the operating system's key store yet), denied to every run and agent by the sandbox (24 §3). The owner keeps them with ensemble browser logins, save-login <site> [--totp] and forget-login <site> (26), or by ticking Save on ‹computer›. The web hears only saved for the one site a card is about.

The computer reports capabilities.logins: 2 (LOGIN_CHECKS_API): it takes code and check, answers needs_code and passed to a server that sends v: 2 in login.resolve (an older server hears failed and signed_in), and tells the server a sign-in it saw done at POST /api/devices/self/logins/:loginId/state. A computer with logins: 1 is sent done for check, and never purpose or a code. Each step is in the ledger on both sides by site, purpose and outcome, never a value.

8.16.2 Taking over the browser

The owner can control a tab of their computer's Ensemble browser from Node, on a phone or another computer (design B21, the plan's §10.2): to sign in with a one-time code from their phone, or to help a run along. Only during a takeover, and only for a sign-in card's tab (Open the browser) or a running run's tab (Open browser on its card, the tab its live view shows). A sign-in's takeover shows the clean browser at the tab's page (§8.16.1), not the tab itself, unless it cannot start on that computer; the answer to start says which (takeover.browser: clean or tab, with a note).

Step What happens
Open POST /api/node/takeovers {loginCardId} or {jobId, tab?} (or {liveCardId} for a live view, §8.17), only from a signed-in browser session (never a key or an agent). The computer must be paired, online, report capabilities.takeover 1 or more (2: the clean browser; 3: live views, control and more time) and have its key pinned; else "Update Ensemble on ‹computer›". The answer is a capability (controller, given once, kept in the page's memory only), good for two minutes and bound to the account, that browser session, the computer, the tab, its site and the card or run, with the computer's pinned key. Someone already controlling the tab (another browser too): 409
Start The page makes a 32-byte secret and seals it for the computer's key (B12 box, purpose takeover, the takeover's id as decisionId, the tab as name, its own site, two minutes). POST …/:id/start {sealed} (with x-ensemble-takeover) checks the box like a secret's and relays it once as takeover.start (protocol 8). The computer opens it (taken once), checks the tab is the sign-in's or the run's (waiting up to 20 seconds for a tab that is opening again, §8.16.1), marks it sensitive, opens the clean browser for a sign-in, and starts Chromium's screencast of the page shown. A start the computer refuses ends the takeover (refused), and the ledger keeps the computer's reason (node.takeover_ended detail, never a value). The server sends mode: "clean" for a sign-in on a computer with capabilities.takeover: 2 (TAKEOVER_CLEAN_API) and none to an older one; the computer answers with the mode it used (clean, or tab with a note when the clean browser cannot start there), which the page is given (browser). A card that asks for a code can be opened too. Each side derives one AES-256-GCM key per direction with HKDF-SHA-256 (salt: the takeover's id; info: the direction)
Frames Each screencast frame, a JPEG with a small header (its size, its page's address without the query, site, title, popup when it is a window the page opened, such as a "Sign in with Google" or payment popup, and mode), is encrypted for the page with the next sequence number and posted to /api/devices/self/takeovers/:id/frames, at most two at once; a frame like the last is not sent. Watching, frames are at most 1024 pixels wide at JPEG quality 45; in control, 1280 at 55; while frames take longer than 250 ms to send they get smaller and rougher a step at a time, and better again when sending is quick (takeover/quality.ts). The server keeps only the newest (a lower number than the one kept is dropped), as ciphertext, for 30 seconds. The page reads them as a stream, GET …/:id/stream?after= (Server-Sent Events: frame with each new message, end with why it ended, a comment every 15 s; Cache-Control: no-store, no-transform, X-Accel-Buffering: no, gzip flushed per event), saying which it has drawn with POST …/:id/seen {seq}: the stream sends a frame only while fewer than two sent ones are unseen (or after 5 s), so a slow link gets the newest picture, not a queue. A page that cannot stream (an older server, a proxy that holds it back) waits at GET …/:id/frames?after= (a long poll) instead. A slow page skips to the newest frame
Input Clicks and taps (as fractions of the page), scrolling, keys, a phone's text box and Paste, each encrypted with the next sequence number: POST …/:id/input, relayed as takeover.input. The pointer's moves are not sent one by one: where it comes to rest (after 120 ms), and while a button is held at most 20 a second; presses, keys and the wheel go at once. The page may have several in flight: the server relays them in sequence order per takeover, holding one that came early for up to 250 ms for those before it (INPUT_GAP_MS, per server process). The computer refuses a message whose number is not above the last it took, or that does not open (changed, or made for another takeover or direction), and applies the rest in order: to the clean browser's page shown as real input (Input.*; keys as a keyboard sends them, so Enter submits and a phone's keyboard types key by key; a burst's commands go at once, in order, and are waited for together), or to a tab through Playwright's mouse and keyboard
More time POST …/:id/extend (with x-ensemble-takeover) moves its end 15 minutes later, at most an hour after it started, and answers {takeover} with endsAt and maxEndsAt; the computer hears takeover.extend {takeoverId, endsAt} (takeover 3). At the hour: 409 longest; an older computer: 409 update
End The sign-in seen done (§8.16.1): its session moves into the node's tab, and the computer ends the takeover itself (reason signed_in or passed); the card says so and the node goes on. I'm done (POST …/:id/end {done: true}): the computer runs the sign-in's check at once; signed in, the card says so (via: "takeover") and the node goes on, else the card stays open with why. Stop ({done: false}) gives a run's tab back as it is; a sign-in's tab stays paused for its card. It also ends at once on its time (15 minutes, ENSEMBLE_TAKEOVER_SECONDS, later after More time), sign-out (the page's session is checked while it waits for frames), unpairing, the tab closing (closed_by_node when its node closed it with browser_close), a minute without input or the page's ping, and when the computer stops taking work from the web

While it lasts the tab is sensitive: no node, agent or model reads, captures or drives it, and its live view is paused. Frames and keys are in no message, model input, log, table or Redis key; only ciphertext passes through the server, and the latest frame waits there for seconds. Each step is in the ledger (node.takeover_created, _started, _extended, _ended; on the computer takeover.started, takeover.extended, takeover.ended with counts of inputs and refusals, never what was typed).

8.16.3 What this protects against

Threat
A model, a node, or a prompt injection in a page Never sees a password, code or takeover frame: the tab is sensitive while it waits or is controlled (nothing reads, captures or drives it), the broker's own code fills it, and the work hears only "signed in" or "not now". browser_type refuses a saved secret's value. A page the agent controls can still try to trick the owner into typing a password into the wrong site; the card names the site, and a typed login is filled only into a tab on that site.
Someone who can read Ensemble's servers, database, logs, Redis or the traffic behind TLS Protected: a typed login reaches the server only as a box sealed for the computer, bound to its card, sign-in and site, valid ten minutes and taken once (§8.7.2); saved logins never leave the computer. A takeover's frames and input pass through as ciphertext under keys only the page and the computer derive, from a secret sealed for the computer. The server sees when and how much: the site, the timing and size of frames and input.
The relay replaying, reordering or changing input, or showing stale frames Refused: each message's sequence number and direction are its associated data, the computer takes only rising numbers, and the page only newer frames. The relay can drop or delay messages, which shows as a stalled view.
Someone else opening a takeover Needs the owner's signed-in browser session: the capability is bound to it, given once, starts once, and works only from that session; a second browser is refused while one controls the tab; signing out ends it.
A compromised Ensemble on the web Not protected, as for secrets (§8.7.6): it serves the page that seals the login and runs the takeover, so it could serve one that sends what is typed, or the frames, elsewhere. Signing in on the computer itself, or a login saved with ensemble browser save-login, keeps it from the web entirely.
A stolen device token It cannot read a typed login or a takeover (sealed for the pinned key it does not hold), and cannot start a takeover (only the owner's browser session can).
Someone with this user's account on the computer Not protected: they can read logins.json and its key, and the browser profile's cookies, as they can the person's other files.
Chromium itself The browser profile keeps the site's session cookies, as any browser does, so the next task is signed in; Ensemble does not use Chromium's password manager. Playwright's own debug log (DEBUG=pw:api), which Ensemble never turns on, would show filled values.
A command a node runs It can hand any page to Ensemble through BROWSER; only pages on the public web (https) or this computer's own addresses open, in the node's own tab, and each still needs the owner on the card. A sign-in code it prints is on a card only the owner sees and in the command's output, never in the ledger.

8.16.4 Sign-ins a command needs

A node signs a command-line tool in with computer_run (az login, gh auth login, gcloud auth login, aws sso login, wandb login…). It uses the browser flow (plain az login, gh auth login --web): the page opens for the owner on a card. A device code (az login --use-device-code) is only for when the owner asks for one or the browser flow failed: some accounts block it (AADSTS530035, security defaults). The DM persona and computer_run's description say so. A command with a login word of its own (SIGN_IN_COMMAND, node/device-tools.ts) runs in the background for 15 minutes whatever the node asked. The computer (capabilities.cliLogins: 1, device-tools/cli-logins.ts) watches it two ways:

What the command does What happens
Prints a sign-in code (Microsoft's "open the page https://microsoft.com/devicelogin and enter the code …", GitHub's "one-time code: XXXX-XXXX" with github.com/login/device, AWS SSO, Google, any "open ‹page› and enter the code ‹code›") The computer finds it in the output as it comes (device-tools/device-codes.ts, foreground and background) and posts it at POST /api/devices/self/nodes/device-codes {nodeId, operation, operationId?, url, code, command, expiresAt?} (device key; operation is the command's run id). The server posts one owner-only card in the command's conversation (node/device-codes.ts): kind: "text", data: {type: "device_code", status: "pending", url, code, command, computer, deviceId, operation, expiresAt} (15 minutes unless the command says), visibleTo: [owner]; its text says the page and the code too. When the command ends, the same route with status makes it done (exit code 0) or expired
Opens a page in a browser It runs with BROWSER (and xdg-open, sensible-browser, x-www-browser, www-browser and gio open on Linux, open on macOS, first on its PATH) set to a small helper in its own folder under the commands' temporary folder, which hands an http(s) address to the runtime through a file there (no network) and passes anything else to the real program. The runtime opens the page in the node's own tab (only pages on the public web over https, or this computer's own addresses) and raises the usual sign-in card (§8.16.1) with source: "cli" and command, at POST /api/devices/self/nodes/logins {nodeId, operation, login}. The owner opens the browser from the card: the clean browser on that computer shows the page, so the command's own answer (redirect_uri=http://localhost:…) lands on the computer and the command goes on. The sign-in is done when a page reaches that address, or when the command ends well; Not now or its time running out stops the command. The card's expiresAt is the command's end

The command's answer comes as soon as the owner is asked (a foreground one goes on in the background; a background one waits a few seconds for its prompt), and the node reads: "Your owner got it on a card in this chat … you may say the code if they ask; never repeat it in a room with other people". Its own computer tools only exist in a chat with only its owner (§8.13). Windows commands keep their own browser; their codes still reach the card.

  • One attempt at a time. A sign-in is known by its program and subcommand (signInWhich: az login for az login --use-device-code, gh auth login). Starting one replaces the node's older run of the same sign-in on that computer that is still going (replaceSignInRun, node/device-codes.ts): the computer stops it (run_stop), its code card becomes status: "replaced" and its sign-in card ended (replaced: true), both with the note "Replaced by a newer sign-in.", and its end wakes no one.
  • When it ends. Its end wakes the node (§8.13), not the card. A sign-in that ran out of time (killed at its limit, timedOut on its last chunk, or ended without success within 15 seconds of it) wakes no one: its card already says it expired. Nor does one whose sign-in card the owner answered Not now (or let expire): the card says so, and the node does not try again unasked (quietCommand, node/logins.ts). One that succeeded or failed earlier wakes the node, the owner being there.
  • Hints. The note that wakes the node adds what to do for a failure it knows (node/sign-in-hints.ts, one row per failure): AADSTS530035 → "Device-code sign-in is blocked by this account's security defaults: run plain az login (browser sign-in) instead."; "No subscriptions found" with a tenant listed → az login --tenant <id> and az account list --all.
  • Never mind. computer_run_stop stops a sign-in like any background command (§8.13).

8.17 Live terminal and browser

A node keeps terminals open on its owner's computer and uses them across calls, and the owner watches a terminal or one of the node's browser tabs live from Node, types in it and takes control, while the node keeps working (node/live-views.ts; on the computer device-tools/terminal-sessions.ts and takeover/terminal-view.ts, 25 §10.4). Only where the computer tools are offered (§8.13: a turn only the owner reads and started), on a computer that reports capabilities.live 1; on an older one the node is told "Update Ensemble on ‹computer›".

Terminals. terminal_open starts a shell (bash, else sh) in a pseudo-terminal in the node's own folder or folder, under the OS sandbox as computer_run is: the first use of another folder asks on the usual card (§8.13) and the call waits for the answer. It answers the session's id (ts…) and its first screen; with live: true it also posts the owner's live card (as live_view would), so a small model watched by its owner needs one call. terminal_send types text (a new line is Enter) and then named keys (Enter, Tab, Up, Ctrl+C, q…), and answers the screen once the shell's prompt is back, or after waitMs (3 s after an Enter, 1 s otherwise, at most 60 s); terminal_read answers the screen, or its last lines with the scrollback; terminal_close ends it. Every line the node submits is judged before its Enter, on the web and again on the computer: the hard floor's dangerous commands (§8.13) are refused and the input is dropped, a command needing the administrator is sent to computer_sudo, and at the shell's prompt the programs no agent runs, fc and history expansion are refused, also for a line recalled from history, continued over several lines or typed by the owner. Keys that would run or insert a line unjudged (Ctrl+O, Ctrl+X, Ctrl+V, Alt keys) and control characters inside text are not sent. Like the floor for computer_run, this is a floor, not a sandbox: the OS sandbox contains what runs. The screens the node reads have secrets hidden. A session ends on terminal_close, its shell exiting, 30 minutes with nobody typing or watching, 4 hours, or the computer no longer taking work from the web; a node keeps at most 3 per computer. The web keeps which computer and node each session is on (Redis, 5 hours).

The card. live_view {kind: "terminal", session} or {kind: "browser", tab} posts one owner-only card in the chat (kind: "text", visibleTo: [owner]), once per session or tab: a second call answers with the same card while it is open. For a tab, the computer checks it is the node's own and watches it.

data
type "live"
kind "terminal" or "browser"
status "open", then "ended" when the session or tab ends (the computer says so at POST /api/devices/self/live/:liveId/ended, or the node or a view finds it gone) or the node ends the view; a view open on the card ends with it
liveId The session's id, or lv + 16 hex digits for a tab
deviceId, computer The computer and its name
title The terminal's title (the node's, else "bash in ‹folder›"), or the page's title (else its site)
session or tab What it shows
startedAt, endedAt? ISO times
endedBy?, endReason? "node" and "closed" when the node closed the tab (browser_close; the computer posts closed_by_node), or "stopped" when it ended the view (live_view {stop: true}); the card says "Closed by @node" or that the node ended the view

Ending a view. live_view {kind, session | tab, computer?, stop: true} ends the card and any view open on it (the view says "The node ended this live view.", reason live_stopped) and leaves the terminal or tab open; the next live_view of that tab posts a new card. browser_close on a watched tab ends its view at once (reason closed_by_node) and its card says the node closed it.

Popups. A view of a tab follows the windows its page opens (a payment or sign-in popup): the newest is shown, its frames say popup: true with its title and site, and Node shows a Popup badge beside the site; when it closes, the window that opened it is shown again. While the owner controls the view, a popup it shows is paused for the node as the tab is, and given back with it.

Watching and control. Open on the card is a takeover (§8.16.2) with POST /api/node/takeovers {liveCardId}: the same capability, sealed start box (its name is takeover.tab, which for a terminal is the session's id), relay of the latest encrypted frame and encrypted input, Stop and time limit. Its TakeoverInfo has kind ("browser" or "terminal"), mode ("watch" or "control"; a live view starts watching, a sign-in's or a run's takeover is always "control"), browser ("tab" or "clean": which browser a browser takeover shows), liveCardId and, for a terminal, session, with endsAt and maxEndsAt as any takeover (More time works here too). POST /api/node/takeovers/:id/control {on} (with x-ensemble-takeover) switches and answers {takeover}; the computer applies it (takeover.control) before the answer. A tab the owner only watches makes way when a sign-in on it is opened from its card: the watch ends (sign_in) and the sign-in's remote view takes the tab; a tab they control stays theirs (409) until they stop. A card whose session or tab has ended answers 410 and ends; one on a computer without capabilities.live 409 (code: "update").

Browser tab Terminal
Watching The tab streams as in a takeover (JPEG frames) while the node keeps using it; the owner's clicks and keys are ignored (pings count) The owner sees the screen and types in it: it is their shell, and their typing is not judged
Control The tab is paused for the node as in a sign-in (it is told the tab is in a takeover), and the owner's input is applied The node's terminal_send waits ("Your owner has taken control…"); terminal_read still works
Handing back The tab is the node's again The node types again

A terminal's frames are the whole screen, sent when it changed, at most 10 a second (changes in between go in the next), up to two on their way at once: the header {t: "frame", kind: "terminal", cols, rows, cursor: {x, y}, title} and the payload UTF-8 JSON {rows: [[{t, fg?, bg?, b?, i?, u?, dim?}]]}, each row as runs of cells that look the same (colours as #rrggbb, absent for the default; trailing blanks left out; a row showing a secret is one plain run with it hidden). Its input: {t: "tty", text} (characters as typed), {t: "tty-key", key} (Enter, Backspace, Tab, Escape, Up, Down, Left, Right, Home, End, PageUp, PageDown, Delete, F1–F12, Ctrl+A…Ctrl+Z) and {t: "tty-size", cols, rows} (the size the owner sees it at; the node's size comes back when nobody watches), in the usual {events: [...]} message. Stopping a view leaves the session or tab going. Each step is in the ledger: node.live_view, node.live_ended, node.device_tool.live, node.takeover_created (with kind), node.takeover_control; on the computer node.tool.terminal_session, node.tool.terminal_line (the program only) and takeover.started/takeover.ended with kind; never what was typed or shown.

8.18 Original files in the Node workspace

The Node website opens original files beside chat, with an independent file list, tabs, resizable chat/editor split, focus controls, themes and selected-line quotes. The UI is in the Node repository; node/file-workspace.ts owns its API.

  • Inventory: GET /api/node/files?roomId=&q=&cursor= returns 50 references and a cursor, newest first. It combines owned agent-job evidence, including sessions started outside Node, with the computer's direct-file cards. Direct references to the same file on the same device are deduplicated; different run checkouts are not conflated.
  • Computer changes: direct file tools register their original paths; commands and live terminals watch filesystem metadata while alive, without following links or scanning dependencies, caches or secret paths. POST /api/devices/self/node-file-changes accepts only that paired computer's authenticated report for an owned node turn. One private kind: "files" card per turn/computer is updated in place. The computer's mode-0600 node-file-sources.json stores canonical roots and opaque IDs, not contents, and queues metadata across outages. Heartbeats retry without delaying leases.
  • Open/save/download: POST /api/node/files/open accepts source and action: read|save|download. It rechecks source and computer ownership, follows a valid successor pairing and requires a browser session from the exact Hub or Node origin. Node does not gain access to the Hub terminal or passkey routes. The computer must advertise codeApi: 3 (CLI/desktop 0.6.4+) and allow browseFiles; saves also require webEdit.
  • Original location: remote/node-files.ts resolves a direct source from the local registry, or a run's exact changed path through its held local job. Ordinary non-Git folders work. Runtime 0.6.6 checks the exact recorded file, not whether its parent could be handed to an agent as an execution root. A safe report is not refused because its parent also holds a sign-in file, a runtime installation or the workspace's helper. Canonical roots, current shared-folder grants for jobs, links, hardlinks, credentials, actual runtime data/code and protected writes are still checked. Browser-supplied raw paths never choose the file.
  • Saving: UTF-8 code, Markdown and text up to 1 MB use an expected SHA-1 byte hash. Writes are serialised per file, recheck the current file, and use its opened descriptor to retain the original inode, ownership, permissions and ACLs as well as BOM and EOL. An I/O failure attempts to restore the prior bytes and explicitly reports a partial save if restoration also fails. A stale version returns FILE_CONFLICT; the UI retains the draft and requires comparison. Keys are masked and read-only; masked or secret-bearing text cannot be saved back.
  • Downloads: a checked, bounded in-memory snapshot on the computer streams through 256 KB relay chunks, up to 100 MB. Secret and archive checks still apply. The snapshot is released afterwards or after two minutes idle; no download creates a cloud original.
  • Account themes: /api/node/themes/search and /openvsx reuse Ensemble's colour-theme services without requiring Code. GET/PUT /api/node/themes/current stores the sanitised palette on an account preference and its ID in ideTheme.activeId, so built-ins, directory themes and imported palettes restore in another browser. node.editor-theme refreshes open Node editors. Extension scripts never run.
  • Last editor and selected questions: POST /api/node/files/target {source} resolves the last recorded direct node or agent session by the file's canonical computer-scoped identity and individual edit time. Runtime 0.6.5 adds these metadata fields to direct reports and RunTotals.files; opening an older job file records its identity without retaining content. Different checkouts are not merged by relative path. The destination is an owned node's DM, keeping the editor open. Missing nodes or unmanaged sessions give an explicit remedy.
  • Direct session questions: POST /api/node/files/query {body, clientId, fileQuotes, target} revalidates the destination, posts the private question and queues it into the editing session with the selected lines. It uses sendToSession with delivery: queue, not a node-model relay, and does not interrupt active work. A direct node edit instead uses normal node dispatch. Message and job operation IDs prevent a retry from submitting another turn/run. A changed destination is FILE_TARGET_CHANGED; the UI never silently sends to the currently open unrelated chat.

The Node website offers a floating quote action beside an editor selection (Alt+Enter too), a Markdown-only Edit/Preview toggle, and built-in Word/PDF/PowerPoint previews reused from Ensemble. Binary previews remain read-only and use the same checked original download; no external document service receives their bytes.

Opening files and unsent drafts keep contents in browser memory only. Explicitly sending selected lines is the exception: those lines are part of the message the person chose to send.

9. Routes

Route Purpose
GET /api/node/bootstrap Me, my nodes, my rooms (with unread counts and last message), my computers, credits, models, templates
GET /api/node/files, POST /api/node/files/open Owner-only original-file inventory and browser read, hash-checked save or streamed download (§8.18)
GET /api/node/themes/search, GET /api/node/themes/openvsx The editor's shared colour-theme directory and safe loader
GET/PUT /api/node/themes/current Account-owned selected theme and sanitised colour data, including imports
POST /api/node/files/target, POST /api/node/files/query Resolve the last editing node/session, then send selected-line questions to that validated destination (§8.18)
GET /api/node/events SSE: node.message, node.message.updated, node.status ({roomId, nodeId, state: thinking|waiting|idle, cardId?, private, threadRootId}: waiting while a tool call waits for the owner's answer on the card cardId (§8.6); private when the turn's thread is the person's My crew, threadRootId the message its thread started from), node.frame (owner only: {roomId, nodeId, turnId, private, threadRootId, frame}), node.live ({jobId, at, paused?}: a run's live view changed, owner only, §8.16), node.room, node.needs (a Needs me card was opened or answered on the person's own channel; no content, so Node re-reads its lists; mirrorNeedsMe in node/events.ts), node.routine ({id}: a routine changed or started a run), node.request ({roomId, requestId}: a peer request changed, to the room's people; Node re-reads it, §12.6), node.cards ({roomId}: something status cards show changed; Node re-reads them, §12.12), node.presence ({roomId}: someone in that room went away or came back, or their first Node stream opened or their last closed; Node re-reads presence, no polling needed)
GET /api/node/assignable My nodes and my crews (with their lead and how many nodes) that a task's owner field offers; empty without nodes (§6.6)
GET/POST /api/node/nodes, PATCH/DELETE /api/node/nodes/:id My nodes (24 at most; helpers are not listed or counted). POST: templateId, name, slug, role, charter, avatar, model; handle and defaults as in §3. PATCH: name (the DM follows), slug (unique per account; each room's handle follows unless someone there has it), role, charter, avatar, model, effort (§5), autonomy {sharedRooms, computerFiles, computerWork, answerAgentQuestions, createNodes, helpers} (§6.1), status (§8.5). Deleting one removes its DM, runs, conversations, session events and helpers
GET/POST /api/node/nodes/:id/memory, DELETE …/memory/:memoryId A node's memory
POST /api/node/nodes/:id/promote Keep a helper as a lasting node (§6.1)
GET /api/node/models Every model a node can use: included, own keys, AI plans on paired computers
POST /api/node/crews A crew from nodes I pick (unique title)
GET/POST /api/node/rooms, GET/PATCH/DELETE /api/node/rooms/:id Rooms I am in (task rooms included, read-only); a new room with 1–3 of my nodes and emails to invite; title (unique) and crew lead by the creator; my own responsibility line. Deleting a task room removes its helper
POST /api/node/rooms/:id/invites, DELETE …/invites/:inviteId, GET /api/node/invites, POST /api/node/invites/:id/accept, POST …/decline Requests to join a room (§3)
POST /api/node/rooms/:id/nodes, PATCH/DELETE …/members/:memberId Add my node (a crew: its creator; a room: 3 per person); make one of mine the lead; leave or remove mine (the creator may remove anyone)
GET/POST /api/node/rooms/:id/messages, POST …/read, POST …/stop Messages I may see (after/before/limit, author=people|nodes); replyToId quotes a message (a public reply may not quote a private one); post (clientId makes a resend return the first copy, private, files: up to 8 of the sender's own Files ids, so body may be empty); read marker ({seq} marks every tab; in a room with other people {tab: "room"|"crew", upTo} marks one: the Room tab is the shared thread, My crew the person's private thread, and the room list gives unreadRoom, unreadCrew and readSeqCrew beside unread, their sum, and readSeq, the Room tab's mark); stop my nodes' work here, or everywhere with {scope: "all"} (§6.4). A message that is only stop or stop all does the same, without a model
GET /api/node/messages/:id, DELETE /api/node/messages/:id One message I may see; delete my own (its text, reactions and attached file names go, a deleted line stays)
POST /api/node/messages/:id/reactions {emoji} Add or take back my reaction (one of eight)
GET/POST /api/node/rooms/:id/pins Pinned messages (25 at most; only ones everyone in the room can read), pin or unpin
GET /api/node/search?q&roomId?&author? Messages I may read containing every word, across my rooms or in one
GET /api/node/rooms/:id/export The room as Markdown, what I may read
POST /api/node/approvals/:messageId §7; also disclosure cards (§12.4) and the target owner's peer-request card (approve accepts, discard declines, §12.6)
GET /api/node/plans/:id One of my plans (or a routine's run) with its steps' run cards, which are not in the timeline
POST /api/node/plans/:id/stop Stops one of my plans (§6.2)
GET/POST /api/node/routines, PATCH/DELETE /api/node/routines/:id My routines (§6.5), with when they run next and their last runs. POST returns a webhook routine's token once
POST /api/node/routines/:id/run, POST …/token, GET …/runs Run now; a new webhook token (the old one stops working); runs with their mission's status
POST /api/node/hooks/routines/:id A routine's webhook: its own token, no session (§6.5)
GET /api/node/secrets/:messageId The card's computer and how a value reaches it: route.mode sealed (with its pinned key), tls or pair_again (§8.7)
GET /api/node/sudo/:messageId An administrator card's command, its SHA-256, the computer, and how a password reaches it: route.mode sealed (with the computer's pinned key) or unavailable with why (§8.13)
POST /api/node/sudo/:messageId {action: run, sealed?} | {action: deny} Answers an administrator card once: run (the password sealed for the computer, or none) or decline; {message} (§8.13)
POST /api/node/secrets/:messageId {sealed} or {value, allow?} Relays the box sealed for the card's computer, or for a computer with no key the value over TLS (§8.7)
GET /api/node/evidence?jobId= (or ?nodeId=), GET /api/node/evidence/:id A run's evidence (or a node's own captures) and one file's bytes, for the account whose computer made them (§8.12); a held file is its owner's preview from the computer (§8.16)
POST /api/node/evidence/:id/release {decision: approve|discard} Release a held capture (uploaded, then usable) or discard it (§8.16)
GET /api/node/live/:jobId The latest picture of a run's browser tab, or 204 (§8.16)
GET /api/node/logins/:messageId A sign-in card's computer, its pinned key for a login or a code typed on the card (route.mode sealed, pair_again or unsupported), its status, purpose (sign_in or human_check) and codeHint, and whether it has a saved login and a screen (§8.16.1)
POST /api/node/logins/:messageId {action: saved|local|done|check|decline} or {action: "typed"|"code", sealed} The owner's answer, relayed to the computer (login.resolve); the card says how it went (§8.16.1)
POST /api/node/takeovers {loginCardId}, {jobId, tab?} or {liveCardId} A takeover of a sign-in's or a run's tab, or a live view of a node's terminal or tab, from a signed-in browser session: the capability (controller, once) and the computer's pinned key (§8.16.2, §8.17)
POST /api/node/takeovers/:id/start {sealed}, GET …/frames?after=, GET …/stream?after=, POST …/seen {seq}, POST …/input {seq, iv, ct}, POST …/extend, POST …/end {done} With x-ensemble-takeover: start it with the secret sealed for the computer, the latest encrypted frame (long poll) or every new one as Server-Sent Events (the page says which it has drawn with seen), encrypted input (relayed in sequence order), 15 more minutes (at most an hour; a live view too), I'm done or Stop (§8.16.2)
POST /api/node/takeovers/:id/control {on} With x-ensemble-takeover, a live view only: take control of the node's terminal or tab, or go back to watching; answers {takeover} (§8.17)
POST /api/devices/self/nodes/device-codes, POST /api/devices/self/nodes/logins Device key: a sign-in code a node's command printed (and later how it ended), or a sign-in page it opened, for the command's conversation (§8.16.4)
GET/PUT /api/node/rooms/:id/trust My settings for this room: what my lead may do for everyone and for each person without asking, and my budget for free-form answers (§12.5). Rooms with other people only
GET/PUT /api/node/rooms/:id/status Every person's status card as I may see them (mine in full); I am away ({away, until?, note?}) or back (§12.5)
GET/PUT /api/node/rooms/:id/shareable Items I marked shareable in this room; {kind: page|task|file, id, spaceId?, shared} marks or unmarks one (§12.3)
GET /api/node/rooms/:id/shareable/candidates?q= What I could mark: my pages (not a task's page), tasks and files in my own space and, when I can open it, the room's linked space; at most 10 of each kind per space, newest first, q (up to 100 characters) searching titles and file names. {spaces: [{id, name, role, linked}], items: [{kind, id, title, spaceId, spaceName, updatedAt, shared}]}, shared when already marked here
GET/POST /api/node/rooms/:id/requests Peer requests in this room since I joined, as the public card shows them, plus answer (a decline note) for the two people and brief, outcome, requirements, ownerCardId, missionId for the person asked; a new one {to, kind, text, refs?, candidate?} (§12.6)
POST /api/node/requests/:id/accept {text?, always?, mode?: private|shared}, POST …/decline {note?}, POST …/cancel The target owner accepts (optionally edited, optionally always for this person and kind, and for a proposed task private work or a shared run) or declines; the requester cancels one nobody decided yet (§12.6, §12.11)
GET /api/node/rooms/:id/space The linked space as I see it: {space: {id, name, ownerId, ownerName, role} | null, offer: {messageId, status} | null} (§12.11)
PUT /api/node/rooms/:id/space {spaceId} | {create: true, name?}, DELETE …/space The room's creator links a space they own, makes one, or unlinks it (§12.11)
POST /api/node/rooms/:id/space/join {join} I take, or leave, the offer to join the linked space as an editor (§12.11)
GET/PUT /api/node/rooms/:id/prefs My preferences for this room: {closeTasks: auto|me} (§12.13)
PUT /api/node/rooms/:id/pages/:pageId {role: contract|decisions|watched} A page of the room's space becomes a contract, the decisions log, or only watched again, by someone who can edit the space (anyone who can read it watches it with …/watch); returns {pages} (§12.14)
GET /api/node/rooms/:id/decisions The room's logged decisions, newest first: {decisions: [{id, text, messageId, pageId, saidBy, loggedBy, supersedesId, replaced, at}]} (§12.14)
GET/PUT/DELETE /api/node/rooms/:id/steward The room's steward: {steward: {nodeId, sponsorId, since} | null}; PUT {nodeId} makes one of my nodes (not my lead) the steward (§12.14)
POST /api/node/rooms/:id/tasks/:taskId/start {mode?: private|shared} I start one of my tasks on the room's board (one from the steward's notes too: starting it is my yes): my lead plans it in My crew; 202 {started, taskId, turnId}. The plan comes from that turn, after the answer: the task's board row then names it (plan) (§12.13)
POST /api/node/rooms/:id/tasks/:taskId/decline {note?} I decline a proposed task on the room's board that is with me: a request's is declined as the request (§12.6); one from the steward's notes goes to Dropped, and whoever asked hears so privately, with the note; {task: {id, status}} (§12.11)
GET /api/node/rooms/:id/pages, PUT …/pages/:pageId/watch {watching} Pages of the linked space the room follows, with role, watchers (a count) and whether I watch each; I watch a page (a page or task page id), or stop (§12.12)
GET /api/node/rooms/:id/board The linked space's board, for its owner and members: {space: {id, name, role}, tasks: [{id, title, status, priority, due, assignee {accountId, name}, requestedBy {accountId, name, node {id, name, handle} | null} | null, peerRequestId, madeBy {node {id, name, handle}, for {accountId, name}} | null, plan {id, status} | null, updatedAt}]}: a node's handle is its handle in this room (null once it left); plan is my own plan for the task (its card's id and status), never someone else's (§12.11)
POST /api/node/messages/:id/share {as: me|lead} Share to room: one of my private messages, or my own node's private reply, published in the room (§12.7)
GET /api/node/approvals The pending cards my nodes' runs raised for their agents (a sign-in or held captures with the run's jobId), each with its origin, node and conversation, and the Node site's address: Needs me in the Hub (§8.6)
GET /api/decisions?surface=hub Ensemble's requests for the Hub's Needs me: a node's own computer-tool questions left out, each run's request with its origin (agent, folder, computer, task, node; §8.6). Without surface, every request, as the CLI and Node read them; answered with POST /api/decisions/:id/decide
GET /api/node/runs, GET /api/node/activity Runs; pending approvals, runs, recent turns, computers, credits, and board tasks waiting for me (waitingTasks)
GET /api/node/sessions/:id, POST …/control, POST …/queue, DELETE …/queue/:jobId One of my sessions: read it, queue, steer, replace or stop, reorder its queue, take a message out (§8.9)
GET /api/bridge/node The CLI's read-only overview (bridge key); its approvals name the node and conversation

10. Limits

  • A node's conversations, connected-app tools and files stay in its owner's account space; its Hub tools reach the other spaces (§5.4). node_agents.run_profile_id is stored and ignored.
  • Only the node's own replies leave history when their space can no longer be reached; the room's messages and the notes the node gets after a run or an approval stay.
  • Requests to join need an existing Ensemble account (the error says so); there are no invite links.
  • There are no voice or phone apps. Live frames come from one API process (as with the Hub's own stream).
  • Node turns use included credits like any other assistant call; there is no separate Node budget. In a room with other people, a person may set a monthly budget for their lead's free-form answers to others there (§12.5); nothing is capped unless they do.
  • Presence on status cards counts open Node streams on the API process, so it assumes one API process (as live frames do); node.presence fires when a person's first stream opens or last closes there, and on away and back.
  • Room events wait 60 seconds to be batched, a sweep reads linked spaces every 30 seconds, and a digest comes after 8 hours without reading a room (or on coming back from away). Presence and frames assume one API process; the sweep covers the rest.
  • A room's linked space takes as many people as its owner's sharing limits allow (2 per space and 5 contacts by default; the owner's choice up to 50 and 500, 29 §1). A room holds as many people as its creator allows (10 by default, their choice up to 50), each bringing up to 3 nodes.
  • Helpers: 4 working per node and 8 per account at once by default (the owner's autonomy.helpers); they read unless their parent grants sessions or computer.
  • Plans: 30 steps each; an attempt of a step stops trying after 4 failed agent runs (runs that work are never counted, and repairs are not capped while each one changes something). Secrets: 100 per computer, 16 KB each, kept only on the computer that runs the agents.
  • Work turns: 3 per node at once, conversation and work capacity 4 each per server (environment settings).
  • A model call waits at most ENSEMBLE_MODEL_CALL_TIMEOUT_MS (15 minutes by default, 30 at most; §5.1).
  • The node's own tools (§8.13): a command runs at most 900 seconds and prints at most 1 MB, 64 KB of it in the answer; a file written is at most 1 MB; computer_http reads at most 64 KB and follows at most 5 redirects, on the computer's own addresses only. There is no list of ports a command started; any loopback port but Ensemble's own may be called.
  • Evidence (§8.12): 100 MB per file of any kind (a zipped folder too; 10,000 files and 1 GB read at most), 100 files per run or per node operation, and a video's poster and preview besides. A file put on a computer from the chat is at most 100 MB. A held capture is previewed from its computer up to 32 MB (a larger video by its poster) and only while that computer is online.
  • Browsing and capture (§8.16): a run's live view keeps one picture per run for five minutes, at most one a second; a node's own tab is watched through a live view (§8.17). A takeover (§8.16.2) lasts 15 minutes (an hour at most with More time), has one controller, and shows the tab at up to 1280 pixels wide in control and 1024 while watching, smaller on a slow link (a phone shows the desktop page scaled down; its text box and Paste type into the focused field). A sign-in waits two hours at most on the computer (a command's as long as the command runs, 15 minutes for a sign-in command), and at least 15 minutes for a routine. There is no toolbar in the computer's browser window for "I'm done": the card's button does it. An Electron app started for capture keeps the computer's network even where the work's internet is off, because Playwright drives it over a local port; a run with the internet off loads pages from the computer's own addresses only (WebSockets are not filtered).
  • Live terminals and views (§8.17): 3 terminals per node on a computer, each ending after 30 minutes unused or 4 hours; 1,000 lines of scrollback; terminal_send types at most 8,000 characters and 40 keys and waits at most 60 seconds; one view of a terminal or tab at a time; a terminal's frames at most 10 a second. A terminal needs the computer's OS sandbox and script (not on Windows). The owner's typing in their terminal is not judged.
  • Parallel work (§8.15): worktrees are made only of a shared folder that is a git repository's own top folder with a commit, as <workspace>/worktrees/<name> on that computer, and stay until an integration run that succeeded removes them (only clean ones whose work is on a branch or already in the folder). A mission that names only a repository address builds in fresh clones and takes no claims; a lead's own build step works in the checkout. Ports come from 4300–4399 per computer; a step gets none in serial work.

11. Verification

With real models. apps/hub-api/scripts/node-smoke.mjs and node-smoke-dm.mjs run against a running hub-api with the server's included models and a scripted computer, and print a verdict. Against a local server they sign up a new account (sign-up must not need Turnstile); against a hosted one they sign in to a verified test account (NODE_SMOKE_EMAIL, NODE_SMOKE_PASSWORD) and use whatever session cookie name the server sets. The first is the reported incident live: a crew asked for two features one at a time, each checked independently; the computer fails the first check, so the build is repaired and checked again before the second feature starts; the owner asks how it is going meanwhile; the plan must succeed with at most five node messages in the crew. The second covers two helpers in parallel reporting once, a routine made from chat, a new node proposed on a card, and stop without a model. node-smoke-room.mjs is a room with two people, each with a lead (§12). Ben's node has a private note in its charter, and Ben has a private task. Ama asks Ben's lead about his work and the note, says something to nobody, asks her own nodes privately, and asks @leads status. It checks that Ben's lead answers in public at most once (with its owner's defaults it asks Ben on a disclosure card and drafts its reply for him), that a message to nobody wakes no node, that Ama's private exchange stays hers, that status cards need no model, and that nothing of Ben's private note or task reaches Ama. They spend included credits.

Choosing the default models. apps/hub-api/scripts/node-plan-eval.mjs gives ten planning requests to a crew's lead on each model under test (NODE_EVAL_MODELS, default Luna low, Sol low and Sol medium). It stops every plan as soon as it is made, so no work runs. It is not a CI gate. A request is handled when the lead makes a plan, or, for the two requests that name no source it can find, asks for the source instead of starting work. Recall is the share of expected deliverables that the plan's requirements name. Checks counts plans with an independent check where one was asked for. Serial counts one-at-a-time order where it was asked for. Results:

Model Handled Recall Checks Serial Average seconds
gpt-6-luna, low 7/10 10/14 4/7 1/1 11
gpt-6.1-sol, low 10/10 14/14 7/7 1/1 24
gpt-6.1-sol, medium 10/10 14/14 7/7 1/1 30

Luna sent three of the eight build requests straight to one coding run, with no plan and none of the checks asked for: the Electron app with a screenshot, the billing refactor with the full test suite, and the flaky test and React upgrade. Sol low planned as well as Sol medium in this set. These measurements do not establish current deployment availability: hosted nodes and leads use the available mini defaults in §5. Evaluators must name deployed models through NODE_EVAL_MODELS; Luna/Sol comparisons require eligible explicit deployments. The lead's medium setting concerns repair judgement, which this set does not measure.

Routing. apps/hub-api/scripts/node-route-eval.mjs gives twenty-one everyday requests from real conversations to a node in its DM on each model under test (NODE_EVAL_MODELS, default Luna low, Luna medium and Sol low), with a simulated computer that answers every computer tool (the web tools of §8.16 with their result shapes: a store blocked, one with a human check, the Azure portal on Microsoft's sign-in page) and uploads a small picture for each screenshot. A request passes when the node used the right tools (a terminal screenshot for htop; commands, downloads and installs with computer_run and computer_sudo; a page's screenshot with browser_view and its focus; prices with browser_shop and the place asked for, in a Markdown table; a paper into Ensemble Files with hub_save_file; a sign-in on the owner's card, with no password asked in chat; joining a crew on the owner's card; a report written and shared; an agent session only for code; a custom board read with the board tools from a pasted link, never a browser sign-in to Ensemble; a terminal the owner watches live; a folder sent zipped; az login run on the computer), as many pictures as were asked came with its reply, its file links are to files a tool saved, and the reply asks for no approval in words. It approves administrator cards without a password. Its computer reports what Ensemble 0.6.2 does (browser 2, logins 2, live 1, takeover 3, nodeFiles 1). It is not a CI gate. Results on all twenty-one (1 November 2026; the first run, then the three cases its fixes touched run again):

Model Passed
GPT-6 Luna, low 20/21 (the zipped folder: it looked for the folder first and the simulated computer did not show it)
GPT-6 Luna, medium 19/21 (the same, and an install it would not confirm after the simulated computer said nothing was installed)
GPT-6.1 Sol, low 21/21

On a real computer. apps/hub-api/scripts/node-live-eval.mjs runs the owner's own requests end to end: it starts a real computer runtime from the checkout in a temporary folder (its own browser profiles and workspace), pairs it with a running hub-api, makes a node on each model under test (NODE_EVAL_MODELS, NODE_EVAL_CASES) and sends the requests from the owner's chats: PS5 prices for Wisconsin with a screenshot of each store and a table, a stock chart, four charts, a CVF paper into Ensemble Files, htop, Switch 2 prices with links, signing in to the Azure portal, a terminal the owner watches live, and az login on the computer. It answers computer cards with Always and declines sign-in cards. A request passes when the right tools ran, as many pictures came as were asked, a table came when asked, the file is in Ensemble Files, the sign-in raised its card, and a model that reads pictures (NODE_EVAL_JUDGE, Luna by default) finds each picture shows what was asked (a store page with US prices and, on Amazon, a Wisconsin delivery place; a drawn chart; htop). Every picture is saved to NODE_EVAL_OUT for a person to look at. It spends credits and reaches real sites from the computer's own network and country, so stores that refuse automated browsing or visitors from that country show as blocked. Results from a computer in India:

Model Passed
GPT-6 Luna, low (the code before this round) 4/6, two of them with wrong pictures (Amazon delivering to India with no price; a blank htop)
GPT-6 Luna, low 5/7, then 3/3 on the three that failed after their fixes (the Azure sign-in routing, the store pictures, a chart pictured before it drew)
GPT-6 Luna, medium 5/7 (before those fixes)
GPT-6.1 Sol, low 6/7 (before those fixes)
GPT-6 Luna, low, 0.6.2 (1 November 2026) 3/3 on the Azure portal sign-in (its card still waiting after 90 seconds: NODE_EVAL_HOLD_SIGNIN_MS), a live terminal (its live card posted) and az login (its sign-in page handed to the node's browser and the card raised)

Web help. src/node/deliverables.test.ts covers the checklist the rules find in the owner's requests and the answer check's rules, without a database; src/node/reply-check.test.ts covers replies that say, promise or ask about work instead of doing it (cases from a real crew's replies). src/node/deliverables.integration.test.ts (PGlite, a scripted model, a simulated computer) runs the answer check through turns: four screenshots asked and three taken get one round and the fourth; "add it to my Ensemble files" with nothing saved gets the file saved (hub_save_file as its contract has it, a stand-in when the build has none); a made-up ensemble://file link is sent back and a real one stands; a request already met gets no round; a stubborn model is told twice at most; a table asked for is checked, and a failure the reply explains stands; with the checklist call on, it adds what the rules miss, is metered, and is not made for chit-chat. src/node/skills.test.ts covers which skills the requests pick and the tool tiers (browser_tabs and browser_close in every turn). src/node/persona.integration.test.ts covers the routing rows and the persona's length (a DM's under 6,800 characters, a crew lead's under 8,000), a DM turn's tools with and without app testing, the conversation's last tools kept, and the skills in a turn's persona. src/node/web-task.integration.test.ts (PGlite, a scripted model and sub-agent, a simulated site) covers web_task: one action per step from interactive snapshots, each call metered; a sign-in page parking the turn on the owner's card with nothing typed; a delete stopped and handed to the node with its tab and button, even when the owner asked for it; a click that opens a payment popup, followed there and named in the result, and back to its opener when the popup closes by itself; a human check; an older computer asked again without mode. src/node/missions.integration.test.ts also covers a node asking to join a crew its owner names, and a crew of nodes that do not exist yet.

src/node/missions-core.test.ts tests the pure plan rules without a database, including the reported incident (a failed check never releases the next feature; the build is repaired and retested), repairs that change nothing, the same failure twice and three times, holding behind unverified work, contract-1 plans, attempt fencing and checks that were skipped. src/node/report.test.ts covers verdicts and failure signatures; src/node/plan-contract.test.ts the plan validation and constraint rendering. src/node/missions.integration.test.ts runs the incident end to end through the API, then: prose asked for once then unverified; a verification refused when a check was skipped or a mandatory criterion is missing; node_plan refusing an incomplete plan and then gating each feature on its check with the constraints in every note; node_plan_revise; a node answering while its own work runs; a newer message merging with a turn that has not acted, never stopping a turn bound to an approval and keeping a merged turn's note; stop without a model; an outbox effect delivered twice starting one turn; a restart re-running and resuming turns, a resumed turn keeping its operation; cards of ended work expiring; a late run from an earlier attempt changing nothing; a worker's structured report ending its step directly; a helper's sessions grant and keeping a helper.

src/node/packet.test.ts covers the packet (each constraint once, settings as facts, repair and retest packets, the length limit, captures checked before they are named) and which steps go straight to an agent; src/node/dispatch.integration.test.ts a crew plan where the lead is the only node that thinks (plan, report) while the build and its check run from packets, a repair in the builder's own session and a retest in the checker's, a run asked once for its result before its node judges, and the owner's card before computer work.

src/node/missions.integration.test.ts also covers helpers: two brought in together report into one plan and their node tells its owner once; a read helper has no computer tools, and a computer grant gives them only in the folders named. src/node/node.integration.test.ts covers a node's proposal card for a new node, approved, and making a crew without asking once its owner allows it.

src/node/dispatch.integration.test.ts also covers media: a plan's report shows the checked video and screenshot (named first, no logs), node_show refuses another account's file, and a file opens for its owner and for nobody who cannot read a message showing it. It also covers the vision check: a blank screenshot named by a passing check fails it, the build is repaired, and only the confirmed screenshot is shown; src/node/vision.test.ts covers the judge model, what the pictures must show and reading its answer.

src/node/capture.integration.test.ts (PGlite, scripted model, a headless Chromium and a simulated computer running the real tool layer and answer code) covers §8.16: a node opening, reading and capturing a page on its owner's computer in one operation, the capture as its own evidence, opened by its owner only and attached by node_show; a held capture on a preview card, refused by node_show, previewed from the computer, released and then shown, or discarded on both sides; an older computer and one without a browser; a run's held capture on a card in the run's room and held on its run card, with an offline computer keeping the card pending; the live view (owner only, a pause with why, the stream hint); and a plan's report showing only media a passing check names when the requirement asks for pictures. src/evidence/capture-run.integration.test.ts covers the coding agents' side (30 §9).

src/node/parallel.test.ts covers branch and worktree names, where each step works, stale checks and the queue keys of worktree runs; src/node/plan-contract.test.ts also the parallel validation and its rendering. src/node/parallel.integration.test.ts runs serial work taking turns in the checkout without a card, two worktree builds at once with each check in its build's worktree and then the integration and the check of its commit, a stale check named in the lead's report, stopping a mission letting go of what it held and its place in line, an older computer building in the checkout, and the claim API for browser profiles, tabs and ports. src/workspace/worktrees.test.ts makes and removes the computer's worktrees in a real repository; src/workspace/folder-claims.integration.test.ts has one mission's runs taking turns without a card.

src/node/cron.test.ts covers schedules in a time zone, clock changes and both day fields; src/node/routines.integration.test.ts a scheduled time started once however often the tick runs, each notification rule, a missed time, pausing and running now, a burst of changes as one run that its own changes do not restart, a webhook's token, redeliveries and a renewed token, and a saved plan run as a plan.

src/node/router.test.ts (routing, rooms v2 and @leads status, brakes, mentions, one node answering in a crew, templates) and src/node/node.integration.test.ts, which runs against PGlite with a scripted model. It covers: a DM turn that uses Hub tools; a session a node starts on its own, off the board, followed to its report; queue, steer (the claim carries only the change; a second change joins the first), queueing after a steer, a replacement that cannot start and stops nothing, replace and stop; no session tools where other people read, even for the owner; isolation between nodes, through any run of a long session, and adopting a session started elsewhere; pausing without a model, not waking for an unaddressed message, and resuming on the owner's message; Ask me first and the card in the Node chat and the CLI but not the Hub's Needs me; handle and name changes; a room where a node drafts for its owner, private threads stay out of shared turns and history, other people cannot see, approve or add someone else's node, and a message that names no node wakes nobody; requests to join (limits, one lead, the 30-day block after a decline, reading only after joining); unique names and crews from chosen nodes; a new node's handle (chosen, from a template, from its name), charter and look, and the template list; search, people-only reading, pins, reactions, replies, deletion and export within visibility; when node_computer_files is offered; steps finishing together start the next one and the report once; declining a step's card lets the step finish; another person cannot have a plan made; a crew plan for the owner's whole request (only the lead answers; two builders in parallel, the tester after both, docs last, one turn each with the reports before them; the owner sees the plan card and one report; step run cards inside the card and not unread); stopping a plan stops its runs and nobody reports; a step whose runs keep failing stops after 4 failed runs and the lead reports, while one whose runs work keeps going; a rate-limited turn retried quietly; node_post refused for its own conversation; a session model named by its label, an unsupported thinking level refused, and an old computer refused for continuing a session; the bridge overview, narrow-key refusals and node deletion; a secret request on a computer too old for a key (refused on one too old for secrets and for reserved names, the card in Needs me, an offline computer refused, the value delivered over the TLS path to the computer's stream and absent from every stored row, the card saying it was not end to end, decline, owner only); a helper (its read-only tools and low effort, the task room, the parent's report in the DM, people refused in the task room, kept out of node lists and crews, removed when idle or when its room is deleted; one brought in from a private thread of a shared room reports privately there; only working helpers count toward the cap); a save and a decline of a secret card never both win, and a card a restart left saving can be answered again; node_create, node_create_group and the thinking level sent with each turn. src/node/spaces.integration.test.ts (PGlite, scripted model) covers §5.3 and §5.4: every assistant tool but the two replaced ones reachable from a node turn, each Hub tool with a space; playbook routing, personal skills, the write policy, Apply and Undo, with the Undo entry naming the node; a task created in a space the owner edits as a member, with Undo there for the owner; a call without a space acting in the node's own space, and in the owner's account once that space is gone; reads but no writes where the owner only views, by tools and routes; the space owner's connected apps, reminders and syncs out of reach; a membership removed during a turn stopping the next call there, and that space's answer leaving later turns; Apply refused after the owner's role there becomes viewer and applied once they can edit again; none of the owner's spaces where other people read, only the room's bound space; node_overview across three spaces; and the delegated identity through routes (ledger attribution, owner-only and personal routes, browser-only gates, another account's node, a space the owner cannot open, headers without the internal token). src/lib/redact.test.ts covers hiding saved values and the reserved names. src/routes/auth.integration.test.ts covers the return to Node after a provider sign-in, src/lib/cors-origin.test.ts covers the Node origin, and src/lib/turnstile.test.ts covers a Turnstile answer from the Node host.

Secrets (§8.7): packages/shared-types/src/seal.test.ts and Ensemble Node's lib/seal.test.ts make the fixed vectors (seal-vectors.json, the same file in both repositories) byte for byte in WebCrypto; src/credentials/seal.test.ts opens them with node:crypto, opens a box made with a fresh key, and refuses a changed ciphertext, tag, IV or associated data, a point off the curve, another key, computer, card, name, purpose or site, and an expired box or one valid too long. src/credentials/credentials.test.ts covers the sites a computer trusts, redaction of encoded values, taking a box once (also after a restart), allowed destinations and the key file (encrypted, 0600, checked, made once). src/node/sealed-secrets.integration.test.ts runs encrypted entry end to end with the computer's own code: the key pinned and its fingerprint in the computer's details; a plain value refused for a computer with a key; a canary saved on the computer and found in no table, log line (trace level), Redis write, frame or model input, as written or encoded; boxes for another computer, card or name, from another page or expired refused by the server, and by the computer for itself; a box replayed, or a second box for an answered card, refused; an older computer on the TLS path, the value relayed but never kept or logged; a changed key pinned out (pair again) and in the ledger; a key that is not its own hash never pinned; and an unpaired computer no longer offered. src/credentials/by-reference.integration.test.ts runs secret_http through a run's MCP server as the agent starts it and through a node's tool call: the API gets the value; the answer hides it as written, base64 and URL-encoded; other destinations, the value in the address, no placeholder, a secret with no destination, a run that may not use it and a run with the internet off are refused; a redirect is not followed; named and compatibility modes; reference secrets never in an environment; and real git credential fill signing in only for the allowed host, in a run that names the secret, while it goes on.

Signing in (§8.16.1): src/logins/logins.test.ts covers one-time codes (RFC 4226 and 6238's vectors, base32 and otpauth seeds), saved logins (encrypted at rest, 0600, listed without passwords, one per site, https or the computer's own address), signed-in URL patterns (a sign-in page's ?next= is not the page), the identity providers and sign-in addresses the heuristics know, logins.json on the deny list, the fields of a clean browser's snapshot, steps of signing in, sites, the code and approval a page asks for, the page judge (a human check is never signed in) and the watch (nothing before the page asked; an analytics cookie is not a session), and the statuses an older server hears. src/logins/signin.integration.test.ts (a headless Chromium, local sites) covers a sign-in seen done in the node's tab only after a change, and told to the web; one asked for on a page that already looked signed in waiting for browser_login_check; a "Robot or human?" page raised as a human check whatever the work said, typed logins refused, never seen signed in; a challenge passed after a click in the tab, and in the clean browser with its cookie moved into the tab; signing in from the web in the clean browser through a "Sign in with ID" popup (shown, typed into key by key, closed, back to the page), the site's and the identity provider's cookies (session ones too) moved and the node's tab reading the signed-in page; the window on the computer doing the same; a typed login asked for a one-time code (needs_code with what the site asks, a wrong code asked again, the code sealed like typed values signing in); and a page with no sign-in form filling nothing. src/browser/broker.integration.test.ts covers the broker's own access to a sensitive tab, its kept lease, showing a tab only where there is a screen, a profile restart that keeps the tab's id, address and sensitivity, and session cookies kept across a restart, the last tab closing and Ensemble restarting (and not brought back after signing out). src/node/logins.integration.test.ts (PGlite, a scripted model, a headless Chromium and a simulated computer running the real broker, tool, answer and login code, against a local site with a sign-in form and a session cookie) covers: a page of another site raising the card by itself, the tab refusing snapshots and screenshots while it waits; a login typed on the card, sealed for the computer: a box for another card refused by the server, one for another site and a replayed one refused by the computer, a wrong password leaving the card open, the right one signing in, the node woken and reading the signed-in page, a plain-http login not saved, the next task already signed in; a canary password found in no table, log line (trace level), Redis write, frame, answer, model input or file on the computer (its keys, logins, ledger and browser profile), as written or encoded; the saved login filling (a stale one leaving the card open), the computer's own address only hinting, browser_login_needed with a URL condition; Not now clearing the tab, telling the node to report it blocked and not asking again; a run's agent waiting in its tool call while the card is where its run is followed, I'm done checking the owner really signed in on the computer, and the node not woken; the computer's login routes refused on the web. apps/cli/src/logins.test.ts covers ensemble browser's arguments, listing and reading a login from a pipe; the Hub's lib/node-login.test.ts and Node's lib/login.test.ts the sealed typed login (opened as the computer does) and the card's words.

Sign-in v2 (§8.16.1): src/node/login-check.integration.test.ts (PGlite, a scripted model and a simulated computer) covers the words that say the owner finished and the ones that do not; "done" checking the card first (check), the turn hearing "Signed in to portal.azure.com; carry on" and the node woken once; still not signed in, with why and the card waiting, other words checking nothing, an older computer asked with done; a human check's card and words, a typed login refused for it, "I solved the captcha" passing it; a typed login turning the card to needs_code (still on Needs me, openable, a code refused before it), the code sent as the sealed box it came as and the node woken signed in; and mode: "clean" sent only to a computer with takeover 2.

Requests in the chat (§8.6): src/node/run-decisions.integration.test.ts (PGlite and a simulated computer on the real device routes) covers a node run's permission becoming an owner-only card in its run's thread with its notification opening it there, the Hub's list leaving it out with nodeChat while the plain list, the node's activity and a run no node follows keep theirs, /api/shell leaving it out, the owner's answer through the decide route (the computer told), answers on the computer and by the node, expiry and the run's end closing it, and nothing left out without a Node site.

Files and links (§6): src/assistant/tools/save-file.integration.test.ts (PGlite, a scripted model, a local site reached through a test-only name, a simulated computer) covers a PDF link saved with where it came from and its link opening the file (and only for its owner), the same file not saved twice, a redirect, names from Content-Disposition and from the type; a web page, a fake PDF, a file over the cap (by its length, and while it streams), loopback and private addresses directly and by a redirect, IPv6 forms, a program and non-http links refused; evidence saved once, another account's, a held one and one kept on the computer refused; the Hub's tool held under preview and saved when immediate; and a node saving a file by path at once though its owner's writes wait. src/node/refs-check.integration.test.ts covers good links of each kind (a shared space's too) and each broken one with why.

Seen done and the clean browser (§8.16.1): src/logins/signin.integration.test.ts (a headless Chromium against local sites) covers sign-ins seen done by a change only, human checks passed and never taken for a sign-in, a typed login that did not move the page, a "Sign in with Google" popup followed in the clean browser and its session moved into the node's tab, a window on the computer, a one-time code after needs_code, and a waiting sign-in's tab that outlives its browser and its page closing (opened again, still paused, the sign-in still waiting) until its work closes it; src/browser/broker.integration.test.ts keeps session cookies across a restart and a closed last page.

Taking over (§8.16.2): packages/shared-types/src/takeover.test.ts and Ensemble Node's lib/takeover-channel.test.ts make the fixed vectors (takeover-vectors.json, the same file in both repositories) byte for byte in WebCrypto and refuse another direction, takeover, sequence number, secret or a changed ciphertext; src/takeover/takeover.test.ts makes them with node:crypto and opens each side's messages with the other's code. src/node/takeover.integration.test.ts (PGlite, a scripted model, a headless Chromium, the computer's real broker, takeover and login code, and a simulated owner's browser using the pages' own channel code) signs in to a local site with a password and a one-time code from the web, in the clean browser: another browser of the same person refused, a key refused, the capability bound to its session and started once; frames opened only by the owner's browser, saying which site they show; a click, typing, a replayed and a changed input refused and not applied; the sign-in seen done, the takeover ended by the computer (signed_in), the session in the node's tab, the card signed in and the node going on; the relay holding the latest frame as ciphertext only; no channel secret, key, frame, password or code in any Redis write, frame, log line or table, and no frame or credential in any model input. It also ends a takeover on its time, on sign-out (the waiting page and the computer hear it; there with the clean browser off, so the tab itself is shown and its frames say so) and on unpairing, and tells an older computer's owner to update; starts the remote view of a sign-in whose tab's browser had closed; moves its end with More time on the computer too, and refuses past the hour; applies 32 keys sent four at a time, some overtaking others, in order and once each, a key within 150 ms at the median (it prints how long a key and a frame took); and signs a command-line tool in (§8.16.4): a sandboxed command opens a local identity provider's page through BROWSER, the card says source: "cli", the owner signs in from the remote view, the answer lands at the command's own address, and the card and the command end signed in, the command's end waking the node.

Sign-ins a command needs (§8.16.4): src/device-tools/device-tools.test.ts covers finding codes (Microsoft, GitHub with colours, AWS SSO and its user_code address, Google, a generic prompt with its life, and prose, pages without a code and this computer's addresses that are not), a code split across output, the command's early answer with the code and the card's done and expired, and pages handed over through BROWSER and xdg-open/open; src/node/device-tools.integration.test.ts a sign-in command run in the background for 15 minutes, one owner-only card per code with its data and text, the node's tool result, the card following the command's end, another account's computer or node refused, and no code in the ledger. Ensemble Node's lib/takeover.test.ts covers where a click lands on a letterboxed picture, keys, batching and a whole session against a simulated computer; e2e/takeover.mjs the view at 390 and 1280 px.

Live terminals and views (§8.17): src/device-tools/terminal-sessions.test.ts runs a real shell in a pseudo-terminal in the sandbox (skipped without one): commands and a variable kept between calls, a program that asks, the scrollback, the owner's size reaching the program and their typing, a session ending by close, exit (with its code) and idle, at most three per node and none of another node's; a dangerous line refused and dropped whether the node typed it, recalled it with Up, continued it over two lines, submitted it with Ctrl+J or the owner typed it first; sudo, fc and history expansion refused, and Ctrl+O, Ctrl+X, Ctrl+V, Alt keys and control characters in text not sent; the screen as runs with colours and styles and a secret hidden; and a terminal view whose frames only the owner's keys open, sent on change at most 10 a second, the owner typing while watching, a replay refused, control making the node wait and the view ending with the session. src/node/live.integration.test.ts (PGlite, a scripted model, the computer's real tool layer, terminal sessions and takeover code, a headless Chromium, and a simulated owner's browser using the pages' own channel code) opens a terminal from a node's turn, posts one live card per session with exactly the card's fields, opens it with {liveCardId} (watching, kind: "terminal"), reads terminal frames, types from the web, sees the node's own command, takes control (the node's typing waits) and hands it back, ends the card with terminal_close and with the shell exiting on the computer, and keeps no screen in the clear on the relay; for a node's tab, watching ignores the owner's click while the node reads the page, control pauses the node and applies the click, handing back gives the tab back, and closing the tab ends the view and the card; a popup the node's click opens is named in its result and shown in the view (popup: true, paused for the node under control) until it closes; live_view {stop: true} ends the card (endedBy: "node") and the view (live_stopped) and keeps the tab; browser_close ends a view with closed_by_node; the frames' stream (/stream: headers, a first comment, frame events opened with the page's keys, end with why, only for the browser that opened the view) and seen; a computer without capabilities.live is told to update. src/takeover/takeover.test.ts covers how a view's frames get smaller on a slow link and better again (takeover/quality.ts). apps/hub-api/scripts/live-view-bench.ts measures a live view end to end (frames a second, bytes a frame, the time from a press or a key to the frame that shows it) against this checkout's real computer runtime, with a shared slow uplink and delay to emulate a home server behind Cloudflare (--profile home); not in CI.

src/node/rooms.integration.test.ts (PGlite, scripted model) covers §12: a room-bound turn never sees its owner's private page, memories or DM, even when asked directly (checked in everything the model was sent and in the tools offered); the owner's own public question is room-bound and My crew never reaches it; one disclosure card per question, declined with a neutral line and respected after, or agreed and drafted by an owner-wide turn that only the owner's post publishes; free-form answers held by trust or by a spent budget; an unaddressed message waking nobody, only leads answering in public, another person's node answered for by their lead, and one answer per person; Ask my nodes with the quote; @leads status without a model, away and detail levels; Share to room; leaving; the migration; a question answered within scope, and a draft answer discarded; work never running before acceptance, then a mission with requestedBy, deliverTo and principals whose end marks the card done; a review's pinned candidate in the mission's constraints, a decline with its note and always accepting; a request relayed from My crew; a planning turn cut off by the rate limit keeping its request; a mission that ended unseen settled by the sweep; peer requests in account export and deletion; and DMs and crews keeping owner-wide turns. The security review's cases: an owner-wide node_post into a room with other people only ever drafts; an approved disclosure's turn has none of the session, computer, plan, work, delegation, helper, post or memory tools and holds writes, and its card shows all it reads; a whole work request on its card; nothing from before the newest person joined reaches a room-bound turn; no routine reports into a room; a recorded request is the author's of the message it names, never starts at once from a mixed batch, and each asker's own free-form gate holds. For §12.11: linking and making a room's space (creator only), settings kept through pins and edits, the offer to each other person apart from joining (and at a later join), the owner's member limit and raising it, Not now then Join, unlinking; work between two editors becoming a proposed task (a private card before joining), nothing running before acceptance, a shared run's mission in the space and private work's in the owner's own, both delivering to the task, a decline dropping it, the Board read, and a task a node made in the space recorded for its person. apps/hub-web/components/desk/tiles.test.tsx covers answering a requested task from the proposals tile. For §12.12: an assignment told once, privately, by the assignee's lead (and a person's own task offered to their builder), with no model; status cards showing the board; a status change told only when someone else made it; a comment naming a person's node and a public message naming the person in one message; an event dropped when the person can no longer open the space; leaving deleting what waited; and the digest on coming back (every request made of the person, the proposed task, the assignment that waited, worded by the model and not repeated by a batch) and after a long gap, once. The review of C4–C5: an away person's 501 held events never delay another person, at most 200 wait, and an ended away counts as back; a transferred space unlinks its room and Join adds nobody; an owner's removal withdraws an open offer; the digest keeps when the away began, lists every waiting event and marks only those it showed; sweeps never overlap and skip quiet rooms; a shared run uses its task's own space while its person can edit there. For §12.13, the extension's worked example (§19) end to end: tasks told once and started in My crew (the owner's yes, and the Start button), a status answer from the card and the board, a work request as a proposed task accepted and done, its video as a file of the space with a Result section and Done, the lead's result post only after the owner's yes, readable media and no run record for the requester, a closed-tasks preference, a review's pass and screenshot on the requester's task, @leads status while away and the digest on return; and a work result drafted even when the lead may post itself. For §12.14: the worked example's step 6 (a contract page, a task that links it, one private message with what changed and the offer, nothing from the editor's own edit, no turn until Ben says yes); a decision logged only on ✅, once, in the confirmer's role, and a reply “log this” that replaces and links it; the steward set by its sponsor (never a lead, never someone else's node), answering Ben in public with only its tools, its notes page and proposed tasks for Ben and Akash written for Ben, and each assignee told by their own lead. For the Node website: a shared message carries its media, which room members open only through the copy; live status and frames saying which thread a turn is in; node.presence on away, back and a person's first stream opening or last closing; a read mark per tab (the old body still marks both); and the shareable picker listing a person's own space and the linked space once they are a member, never another person's own space. Also: a request's public card with only the requester's words (a draft from My crew sent in the words approved, the owner's brief, decline note and work kept off it, answers linked, not copied); and a room-bound turn's instructions naming no space of the owner's, only the room's. From the review of C6–C7: work linked to a room's task only by Start or an explicit yes to the offer (“Not now, do this instead” stays private); a deleted shared copy, as the person or as their lead, opening no media; the steward writing for nobody when two people ask it in one turn; the steward ending with its node, its sponsor or the node's deletion, with nobody inheriting it; only editors changing a page's role; no reacting to, pinning or logging a message from before one joined; and one decisions log for two confirmations at once. Also: a requested task moved out of Proposed on the board answering its request, for the person asked only; people per room and spaces as each person's own limits (the maker's for a room, the receiver's for a handed-over space); declining a task from the steward's notes, the chosen mode on the owner's card, Start's turn and the board's own plan, and requesters with their node's handle on the board and in the digest. src/node/router.test.ts covers the steward's routing.

12. Rooms with people

A room with other people (kind room) follows rooms v2 (ensemble-node docs/design, extension Part B; build blocks C1–C7). People talk to people and to leads. Each person works with their own nodes in private. A node acts for its owner only: another person's message is information, never an instruction. Work on someone's computer, subscriptions or credits never starts without that person's yes. Nothing private reaches a public answer: code enforces it, not the prompt.

12.1 Room and My crew

  • Room: what everyone in the room reads. People's messages, leads' public answers, request cards (§12.6), status cards (§12.5) and system lines.
  • My crew: a person's private messages in the room (visible_to = [them]) and their own nodes' private replies. Their nodes read the Room too. Nobody else sees it.
  • The composer. Send to everyone, or Ask my nodes: a private message, which may reply to a Room message (replyToId). The node's transcript carries the quote (↪ #12 “…”).
  • Share to room publishes a private result (§12.7).

12.2 Who answers in a room

Message Who answers
A person, naming no node Nobody. No suggestion and no model call.
A person names a lead That lead, in public, as a room-bound turn (§12.3), even when its own owner asks.
A person names their own other node That node, privately, in their My crew.
A person names another person's other node That person's lead, in public. Its note says the node was named and that only leads answer in public. One answer per person, however many of their nodes are named.
@leads status (also @leads, status?, @Leads: status!; nothing else) One status card built by code, with no model turn (§12.5).
A private message (My crew) The person's own nodes, privately: the ones they name, else their lead.
A node's message, a card, a system line Nobody. Nodes never wake nodes here.

At most 6 nodes answer one message. The streak and depth brakes (§4) stay, but cannot trip here.

12.3 Room-bound turns

A turn's audience (node_turns.audience) is set by where its answer goes: room for a turn in the shared thread of a room with other people, owner for My crew, DMs, crews, plan work and helpers. The router sets it when the turn is queued and roomBoundTurn (node/audience.ts) checks it again when the turn runs. It is never widened. Shared and private threads keep separate conversations (§5.2).

A room-bound turn
Instructions A room persona: its name and role, the room's members by their public fields, who asked, the owner's away line, what it can reach and how to answer. No charter, memories, other nodes, rooms, work board, personal skills or connected apps. The assistant's base instructions name no space of the owner's (where in runAssistantTurn): “You're working in an Ensemble room with other people”, and the room's linked space when it has one.
Transcript The shared thread only, from when the room's newest person joined (sharedSince): nothing anyone there could not read. A turn woken by an older message does not run, and every node's shared thread starts afresh when someone joins. Files on messages are named, never opened.
Tools node_room_history (from the newest person's join), node_owner_status, node_shared_items, node_ask_owner, node_peer_request (from: the message a request comes from), hub_web_search, and the room's bound shared space when it has one (settings.spaceId, bound in C4) through the space gateway: home only, never falling back to the owner's own spaces, and only while the owner's settings answer from it. Nothing else.
Scope It runs as a guest of the owner's own account, so their connected apps, reminders, imports and syncs are out of reach.
Writes Preview: every write waits for the owner as an approval card.
Posting Only its own reply may go straight into the room, and only when the owner lets the lead post there itself (autonomy.sharedRooms: auto). Anything else posted into a room with other people (node_post from any owner-wide turn, a helper's reply) is always the owner's draft.
Principals node_turns.principals {data, billing, executionDevice, approver, askedBy}: the owner's data and credits, and who asked.

Shareable items. PUT /api/node/rooms/:id/shareable {kind, id, spaceId?, shared} marks a page, task or file in a space the owner can open. The picker lists candidates from the owner's own space and the room's linked space (…/shareable/candidates), never from anyone else's. node_shared_items lists them and reads one at that moment, with the owner's access then.

12.4 Disclosure cards

  • node_ask_owner {question, need} raises one private approval card for the owner (data.type: "disclosure", mode: "share"): “Ben asked: “…”. May I share: …?”. There is one per room, asker and question (and one per message). It is settled once; a late answer gets 409, and the card expires when the asker leaves.
  • Yes. A private turn of the lead in My crew (operation disclose:<card id>) looks it up with the owner's reads across their spaces and writes the answer. It runs as a non-owner turn for tools: no sessions, computer, plans, work, delegation, helpers, posts or memories (only node_overview of its own tools), and every write waits on a card. The card shows the question and the need in full, exactly as the turn reads them. The answer reaches the owner as a “Post this for you?” draft (data.disclosure), which they may edit. Only the owner's post publishes it. Discarding the draft shares nothing.
  • No. Nothing is shared. The room gets a neutral line from code: “Akash chose not to share that.”
  • Free-form answers. When the owner's setting for an asker is ask, or the room's budget is spent, that person's question does not run the model. Each person asking in a turn is checked on their own: whoever must wait gets a card with mode: "answer" (“Ben asked me: “…”. Answer it? It uses your credits.”) and their messages leave the turn, which answers only the others (or does not run). Yes runs the room-bound turn (reason: answer); no posts “Akash chose not to answer that.” The owner's own public questions are never held.

12.5 Trust, status and away

Each person decides, per room, what their lead may do for everyone and for each person without asking (GET/PUT /api/node/rooms/:id/trust, stored on their own row as node_room_members.prefs). These are the person's choices, not product limits.

Setting Options Default
status answer, ask answer
statusDetail nothing, titles, details titles
shareSpaceArtefacts answer, ask answer
shareOther ask, never ask
workRequests, acceptKinds ask, or accept for the kinds in acceptKinds (only review and work need accepting; the lead answers questions and shares) ask
freeFormAnswers allow, ask allow
budget (per room) included credits a month, or none none

A public answer is read by the whole room, so what it may disclose (status, detail, shared space, other items) follows the most careful setting for anyone in the room. Free-form answers and work requests follow the asker's own. The budget counts the credits of the owner's room-bound turns asked by others this month (UTC).

Status cards (node/status-cards.ts), per person and room, built by code:

Part Shown at
Lead, presence (online: an open Node stream, or a message in this room in the last 5 minutes; away; offline), away status Every level
Missions for this room (started here or delivering here; live, or ended in the last 3 days): title and status titles, details
Tasks assigned to them in the room's bound space, open ones, when every reader can open that space titles, details
Requirement status, steps done, due dates details

GET /api/node/rooms/:id/status shows each card as the caller may see it, their own in full. status: ask shows presence only. @leads status posts one status message with data.type: "status_cards", cards and replyTo: in public with the room-wide view, or privately to the asker with theirs.

Away. PUT /api/node/rooms/:id/status {away: true, until?, note?} (ends at until), or {away: false}. The lead's room-bound turns are told, say so, answer status, and leave work for the owner. Work they would accept automatically waits too.

12.6 Peer requests

A peer request (node_peer_requests, node/peer-requests.ts) is a record, not a chat: room, from (a person, and their node when it relayed it), to (a person, through their lead), kind, text, refs, candidate, status, answer, answerMessageId, brief, missionId, and its two cards. It is made from the composer (POST /api/node/rooms/:id/requests {to, kind, text, refs?, candidate?}, to an account id or a handle), from My crew (node_peer_request, attributed to the person and relayed by their lead), or by a lead recording someone's request for work for its owner. From My crew it is first the person's draft (a post card with data.request): their lead's turn read private things, so the request reaches the room only when they send it, in the words they approved. The draft's text shows everything the room would read, refs and candidate included; sent, its refs stay in the text, and the candidate goes only while each of its values is still in the text. A recorded request names its message (from: a public message the turn read from a person other than the owner, addressed to the owner's nodes); that message's author is who asked, and their own trust applies. A request recorded from a turn that read several people's messages never starts at once: the owner decides. Leads never negotiate with each other in public.

Kind Flow
question, share The target's lead answers in the room, as a room-bound turn (reason: request), with only what the room may see. Private information goes through a disclosure card. open → answered, or awaiting_owner while a draft or card waits for the owner; a draft the owner discards declines it.
work Nothing runs before the target owner accepts. Their private card (data.type: "peer_request") offers Accept (optionally in their own words for their lead: brief, which stays theirs; the room keeps what was asked), Decline with a note (for the requester alone: on their view of the request and as a private line in their My crew), and Always accept requests like this (their trust for that person: workRequests: accept and the kind). Accepting gives their lead one private turn in My crew (operation request:<id>:accepted) to plan it with node_plan. Its mission records requestedBy {accountId, nodeId, roomId, requestId, kind}, deliverTo {roomId, audience: room} and principals (missionOriginFor, called where node/plans.ts makes missions). When the mission ends, code marks the request done. Nothing from the work goes on the public card: the result reaches the room only as the owner posts it (§12.13). The lead's report stays in My crew, for the owner to share.
review A work request whose candidate is pinned: a commit (7 to 64 hex characters) with its repository, or a URL. On accepting, the mission's constraints carry the repository, publish: none and notes: a fresh clone at exactly that commit, no secrets, sandboxed with network only for the checks, pass or fail with each check, and no repair. Ensemble does not yet grant the reviewer read access to the commit (for example on GitHub).

Statuses: open, answered, awaiting_owner, approved, declined, done, cancelled. The public card (request message) is updated in place; every change sends node.request to the room's people. It shows only what the requester wrote (text, refs, candidate), who asked whom, how it stands, and links: the public answer (answerMessageId; never a copy, so deleting the answer leaves nothing behind) and the task. Through GET /api/node/rooms/:id/requests everyone in the room sees the same; the two people also the decline note (answer); the target owner also their brief, how the work went (outcome, requirements), their card and the mission. A requester's account export leaves out the target owner's brief. Accept and decline are the target owner's alone (POST /api/node/requests/:id/accept|decline, or their card in Needs me); the requester may cancel one nobody has decided yet. A decision checks that the other person is still in the room, and so does the delivery when the work ends.

12.7 Share to room

POST /api/node/messages/:id/share {as: me|lead} publishes one of the owner's own private messages, or their own node's private reply, in the room. As me it is the person's message; as lead, their lead's, with data.sharedBy. Both keep data.sharedFrom {id, seq, authorKind, authorNodeId}, refs, files and media (the copy is the message that shows the media to the room: GET /api/node/evidence/:id?message=<copy id> opens it for the room's people; the private original still shows it to nobody else). A shared message wakes nobody, and sharing the same message twice returns the first copy. The copy is the sharer's to delete (DELETE /api/node/messages/:id), whether shared as them or as their lead; deleting any message drops its files, media and refs, and a deleted message opens no media.

12.8 Leaving

A person who leaves, or whom the room's creator removes, takes their nodes along (§9). In the same transaction:

  • the trust others in the room gave them goes;
  • their undecided requests, made or received, are cancelled;
  • their nodes' queued conversation turns there are dropped (plan turns stop as for any removal);
  • pending cards for them or about them there expire: their own cards, disclosures they asked for, and requests they made. Answering one gets 409.

Requests already accepted go on as the owner's own work; their delivery checks again that both people are still there.

12.9 Migration

Migration 20261110120000_node_rooms_v2 switches every room with other people to v2: settings.version = 2, one status line (“This room now follows rooms v2: only leads answer in public, and only when addressed. Use My crew to talk to your own nodes.”), and its nodes' shared-thread conversations start afresh, so no room-bound turn sees history from before. History is kept. Rooms made since are v2 from the start.

12.10 Card data

Message data
Public request card (kind: "request", system) {type: "peer_request", requestId, kind, status, text, refs, candidate, from {accountId, name, handle, nodeId, nodeHandle}, to {accountId, name, handle, leadNodeId, leadHandle}, answerMessageId, taskId, mode, updatedAt}: only what the requester wrote, and how it stands
Request draft (kind: "approval", the lead, private to its owner) {type: "post", status: pending|applying|posted|discarded, request {kind, to {accountId, name, handle}, candidate}, requestId?, error?}: sending it makes the request with the card's (edited) text
Target owner's card (kind: "approval") {type: "peer_request", status: pending|applying|accepted|declined|cancelled|expired, requestId, kind, text, refs, candidate, from {accountId, name}, nodes [{handle, name, role, lead}], cost, brief?, note?}; with a proposed task also taskId, modes: ["private", "shared"], defaultMode: "private" (§12.11); once accepted, mode (private|shared), the way the owner chose it runs
Space offer (kind: "status", system, private to the person) {type: "space_offer", status: pending|joined|declined|expired, spaceId, spaceName, ownerName, role: "editor"}
Space linked or unlinked (kind: "status", system) {roomSpace: {spaceId, action: bound|unbound}}
Room events (kind: "text", the person's lead, private) {type: "room_events", items: [{kind: assigned|status|mention|page_edited, subjectKind: task|comment|message|page, subjectId, title, spaceId, actor {accountId, name, nodeId} | null, at, …}], start? {taskIds}, suggest? {nodeId, handle}}; status items add from, to; mentions excerpt (and pageKind, pageId or seq); page edits revision, role
Contract change (an item in room_events) {kind: "contract_changed", subjectKind: "page", subjectId, title, actor, revision, diff {added [line], removed [line]}, tasks [{taskId, title}]}
Declined, to the requester (kind: "status", system, private to them) A request's decline note: {requestId, declineNote: true}; a task from the steward's notes declined: {taskId, declined: true, declineNote?: true}
Decision logged (kind: "status", system) {type: "decision_logged", decisionId, messageId, pageId, spaceId, text, supersedes? {decisionId, messageId}}
Page role set (kind: "status", system) {pageRole: {pageId, spaceId, role, was?}}: was when a contract or the decisions log became only watched
Steward set or ended (kind: "status", system) {steward: {nodeId, sponsorId?, action: set|ended}}
Result draft (kind: "approval", the lead, private to its owner) {type: "post", status, result {missionId, requestId?, taskId?, spaceId?, outcome, verdict?, requirements [{text, status}], checks? [{name, verdict}], files}, media?, refs? [{kind: "task", id, spaceId, title}]}
Result in the room (kind: "text", the lead, public, after the owner posts it) {type: "room_result", approvedBy, missionId, requestId?, taskId?, spaceId?, outcome, verdict?, requirements, checks?, files, media?, refs?}
Catch-up digest (kind: "text", the person's lead, private) {type: "digest", reason: back|gap, since, items: {asked [{requestId, kind, from {accountId, name}, text, status, taskId}], proposed [{taskId, title, requestedBy {accountId, name, node {id, name, handle} | null} | null}] (as on the board), events [as above], board [{taskId, title, status}], results [{messageId, text}], decisions [{messageId, text}], room {messages, from}}}
Disclosure card (kind: "approval") {type: "disclosure", mode: share|answer, status: pending|applying|approved|declined|expired, key, asker {accountId, name}, question, need, triggerMessageId, requestId, shared?, why?}
Status cards (kind: "status", system) {type: "status_cards", cards [{accountId, name, handle, lead, presence, away, detail, asks, missions, tasks}], replyTo {id, seq}}
Shared message (kind: "text") {sharedFrom {id, seq, authorKind, authorNodeId}, sharedBy? (as lead), refs?, files?, media?}
A draft answering someone (kind: "approval", type: "post") adds disclosure (card id) and/or peerRequest (request id)

12.11 The room's shared space

The room is the conversation; a space holds its work (build block C4; node/room-space.ts). The room's creator links a space they own, or makes one for it (“Room space”; 28). The link is settings.spaceId. A room's settings keep every key through edits and pins (roomSettings in node/store.ts), so version and spaceId survive.

  • Linking posts one status line and offers each other person membership as an editor: a private card (space_offer) with Join and Not now. Joining the room is not joining its space; that is a separate yes (POST /api/node/rooms/:id/space/join {join}). Someone who joins the room later is offered it then. Not now can become Join later. Joining adds them for the space's owner, who offered it by linking the space, within the owner's sharing limits (29 §1); at the limit the person is told the owner can raise it in Settings › Sharing.
  • Unlinking (DELETE …/space) leaves the space, its board and its members as they are; pending offers expire.
  • Only while the creator owns it. A room links a space only while its creator owns it (linkedSpace). After a transfer, or when the space is gone, the room is unlinked at its first use (one status line, data.roomSpace.why: "owner_changed"), and nothing more is offered, proposed or followed there.
  • Losing a space withdraws offers. When someone loses a space (its owner removes them, or removes them as a contact, or they leave), their open offers to join it through a room expire (onMemberRevoked in sharing/revoke.ts), so Join cannot bring them back.
  • Room-bound turns reach the linked space only, with the person's own role there (§12.3). A viewer's nodes read and never write; the space owner's connected apps never reach members' nodes (29 §2).
  • The Board tab reads GET /api/node/rooms/:id/board: the space's tasks (not dropped), each with assignee, requestedBy (with the node that relayed it, by name and its handle here), peerRequestId, madeBy (“@node for person”) and plan (the viewer's own plan for it, theirs only), for the space's owner and members only.
  • Declining a proposed task (POST …/tasks/:taskId/decline {note?}), by the person it is with. A request's task is declined as the request. One from the steward's notes has no request behind it: it goes to Dropped, and whoever asked for it hears so privately in their My crew, with the note, as a declined request's note reaches its requester (their lead does not tell them again). Starting such a task (…/start) is the person's yes: it leaves Proposed as the work starts.

Work becomes a proposed task. A work request (§12.6) between two current editors of the linked space (its owner counts) becomes a proposed task there, assigned to the person asked, with who asked (requested_by_account_id, requested_by_node_id), peer_request_id and source_ref room-request:<id> (05). Otherwise it stays a private card only. The target owner's card names the task and asks the one question when accepting:

Mode Where the mission runs What members see
private (default): private work, shared result The assignee's own space (node_missions.space_id null) The result and evidence delivered to the task, never the run's transcript or code
shared: shared run The proposed task's own space (node_missions.space_id), wherever the room links now, while the person asked can still edit there (else it runs as private work); the lead's note says where The run, as for any member's run there

POST /api/node/requests/:id/accept {text?, always?, mode?} records the mode on the request. The task follows the request: accepted → In progress (keeping what was asked; the owner's edit is their lead's brief), declined or cancelled → Dropped. The Hub's proposals tile answers it too, and so does moving the task out of Proposed (answerRequestedMove in services/tasks.ts, answered by answerFromBoard): the person asked moving it to To do or In progress accepts it as private work, Dropped declines it; anyone else, or another column, is refused with why, and nothing else moves it out of Proposed while the request waits. The mission's deliverTo carries taskId.

12.12 Room events and catch-up

What concerns a person in a room reaches them once, privately, from their own lead (build block C5; node/room-events.ts). No model runs.

Event From Who hears of it
assigned A task in the linked space now with them (not a requested task: its card asks them) The person it is with, including when they took it themselves
status A task's status changed (a request's decision and its end are on its card instead) The person it is with and the person who asked for it, unless they made the change
mention A comment in the space naming them or one of their nodes (by room handle, or as their Ensemble contact); a public room message naming them The person named, unless they wrote it
page_edited An edit to a page of the space they watch (PUT /api/node/rooms/:id/pages/:pageId/watch) Its watchers, unless they edited it
  • Finding changes. A room follows its linked space from the moment it is linked (node_room_feeds); what is there then is the baseline. Space frames (task, page, assistant.acted, undo.recorded) start a read at once. A sweep every 30 seconds (never two at once) reads what no frame announced: up to 100 rooms, longest-unread first, and only those whose space stirred since they last read it (a task changed, a comment, a watched page, or the link changed); reads go one at a time per room. Rooms linked by other means start following every 20th sweep. Code compares each task with what the room last saw of it (node_room_tasks). The undo entry names who made a change. A public message naming a person wakes no node: their lead notes it for them instead (extension §21, question 1).
  • Once. Each change is one row per person (node_room_events, unique per room, person and change). Rows wait BATCH_MS (60 seconds), then each person gets one private message from their lead in My crew (data.type: "room_events"), however many changed: “Akash assigned you “CSV export”. Shall I start it with @tester?”. They answer their lead as usual.
  • Access, again. Each delivery checks that the person is still in the room and, for the space, that the room still follows it and they can still open it. Anything else is dropped, never delivered.
  • Status cards stay current: every change the cards show sends node.cards {roomId} to the room's people, who re-read them.
  • Away. Events wait while a person is away: out of the delivery queue until the away ends (or a recheck every 6 hours), so they never hold up anyone else's. An away whose end has passed counts as back. At most 200 wait per person and room (the newest), and none waits more than 14 days.
  • Leaving deletes what was waiting for the person and takes them off the room's watched pages, in the leave's transaction.

Catch-up digest. When a person comes back (PUT …/status {away: false}, or they open the room after their away time ended), or opens the room after 8 hours or more without reading it, their lead posts one private digest (data.type: "digest"). Code gathers the facts since then: every request made of them, proposed tasks waiting for them, every event still waiting for them however old, the events told since, other board changes, results delivered and decisions logged in the room, and who wrote in the room. Being away again while away keeps when the away began. A model (the person's own easy tier, their usage) only words at most five short lines; without it, the first five facts are the text. data.items lists everything. Only the events it showed are marked delivered, so no batch repeats them, and what arrives while it is written waits for the next. One digest per return.

12.13 Results into the room and onto the board

A mission whose result goes to a room (deliverTo {roomId, audience: "room", taskId?}) is delivered once, by code, when it ends (build block C6; node/room-results.ts). The mission's deliverTo.deliveredAt marks it. A request's mission waits until its request is settled; a request cancelled because someone left delivers nothing.

Where What
The board The mission's checked evidence (as for a plan's report, §8.14) becomes files of the room's space tagged with the task. The task's page gets a Result section: how it ended, each requirement, a review's checks, and the files. Work for the person the task is with moves it to Done when it succeeded, unless they close tasks themselves (PUT /api/node/rooms/:id/prefs {closeTasks: "me"}; then it stays In progress). A review's result goes on the requester's own task (the task the review request refers to, on the room's board), which stays theirs to move. The owner must still be able to edit the space.
The room The lead's post: “Invoice download is ready.”, each requirement, a review's checks, and the task, with the media. It is built from the mission's ledger and evidence only, never from the lead's private report, and it is always the owner's draft (data.type: "post", data.result): only a room-bound reply ever posts at once (§12.3). Posted, it carries data.type: "room_result".

Starting a room task. The person a task on the room's board is with starts it from My crew: POST /api/node/rooms/:id/tasks/:taskId/start {mode?: private|shared} (the Start button on their lead's message, whose data.start.taskIds names the tasks), or with an explicit yes to their lead's offer of one task: the whole message is a yes (“yes”, “yes, start it”, “ok, go ahead”) and replies to the offer or comes right after it. Code takes that yes as the Start button (startOnYes), and the message wakes nothing else. Their lead gets one private turn to plan it; its mission delivers to the task and the room (roomTaskOrigin, from missionOriginFor). Only such a start links work to a task: whatever else the owner says next (“Not now, do this instead”) is ordinary work for them, and none of it reaches the room's space. The task moves to In progress when the work starts. A requested task is started by accepting its request instead, and one task has one mission at a time.

Reviews. The review's planning note asks for one checking step of kind other (nothing in the plan builds the candidate), reporting each check with its verdict. The result says Review of <candidate>: pass or fail, from the checks and how the mission ended. Accepting a review whose request refers to a task on the board says, on the owner's card, that the result goes there (resultTo: "requester_task").

12.14 Contracts, the decisions log and the steward

Build block C7 (node/room-pages.ts, node/steward.ts; extension §17.2–§17.4). Each needs the room's linked space.

Page roles. PUT /api/node/rooms/:id/pages/:pageId {role: contract|decisions|watched}, by someone who can edit the space, for every change of role, back to watched too. One status line says so, also when a contract or the decisions log becomes an ordinary page again (was).

Role What it does
contract The interface two people's work meets at. When it changes, each affected person gets one event (contract_changed): its watchers, and the people whose open tasks on the board link it (its id in the task's description, notes or page). They hear from their own lead, privately, in the usual batch: what changed (lines added and removed against what they last heard), which of their tasks it touches, and an offer (“Shall I steer @builder to adapt your work to it?”). Nothing changes until they say yes. The editor hears nothing of their own edit.
decisions The room's decisions log, one per room.
watched Edits reach its watchers (§12.12).

Commits touching files a contract links are not watched yet: contract changes come from page edits only, as for a watcher without repository access.

The decisions log. A room message enters the log only when a person confirms it: ✅ on it, or a reply “log this”, and only a message from since they joined (as with reactions, replies and pins, which never reach messages from before a person joined). The log page is chosen or made under a lock on the room's row, so confirmations at the same moment share one log. A lead may suggest one (“Shall I log: …?”); its suggestion logs nothing until someone confirms it. Code appends one line to the log page (made on first use, “Decisions: <room>”) in the confirming person's own role: the decision, who said it, the message number and who confirmed it. A decision that replies to a logged one, or says “replaces #12”, links it (“Replaces the decision of …”). Each message is logged once. The room gets a status line (data.type: "decision_logged"), which the catch-up digest lists. Only people who can edit the space log decisions; anyone else is told privately. GET /api/node/rooms/:id/decisions lists them, newest first.

The steward (on request only, extension §21 question 5). A person makes one of their own nodes in the room, not their lead, its steward and sponsors it: PUT /api/node/rooms/:id/steward {nodeId}. Its sponsor or the room's creator ends it (DELETE); it ends when its sponsor leaves, or its node leaves the room or is deleted, and no other node takes its place (a crew's lead passing to its manager is for crews only). Rooms with people keep it in settings.steward {nodeId, sponsorId, since} and in leadNodeIds, which they use for nothing else; nobody sets those through PATCH /api/node/rooms/:id.

  • Who it answers. Anyone who names it in public. It answers in public, for them, as a room-bound turn: the room's history from the newest person's join, node_room_board (the board as the asker sees it), node_steward_notes and web search. No status card, shared items, disclosure, peer requests or space tools of its sponsor's. Messages from several people can reach it in one turn (its sponsor's included): it then acts with the least of their rights, reading the board only if all of them can open the space and writing for nobody, so it says so and each asks again on their own.
  • Meeting notes. node_steward_notes {title, notes, actions [{to, task}]} writes a page in the room's space and a proposed task for each action, assigned to the person named (their handle or one of their nodes'), in the asker's own role (“@steward for Ben”: created_by_node_id, created_by_account_id, requested_by_*, source_ref room-notes:<page>). People who cannot edit the space get none. Each assignee hears from their own lead (§12.12) and accepts or declines the task.
  • Board tidying. It reads the board and suggests changes; people make them.
  • Gaps. It is the sponsor's node, not one owned by the space: its model usage, and approving its posts unless they let it post itself, are the sponsor's. It does no chores unasked, so it has no budget of its own beyond the sponsor's room budget for free-form answers (§12.5).