30 · Engines: who does the work, and how their questions reach you
A code task is done by one of:
- Ensemble's agent: Ensemble's own tool loop calling a model with the selected credential or funding source (
apps/hub-api/src/workspace/agent.ts). - Claude Code, Codex, Cursor's agent, GitHub Copilot, Grok Build, OpenCode, Gemini CLI, Google Antigravity, Mistral Vibe, Qwen Code or Kimi Code on the person's computer, signed in with their own account (§1–§3).
- The agent's own app or a terminal: the Claude app, Codex in the ChatGPT app, Cursor or VS Code; or Claude Code, Codex, Cursor's agent or Copilot CLI in a terminal window (§4).
The Assign dialog's agent picker picks one (or Auto: the agent Settings → Models names for the task's complexity); run profiles remember it. When Ensemble runs the task, the same run settings apply: mode (Ask, Review only, Run unattended), folder trust, sandbox, outside-folder access, git permission, internet and limits (24, 06). In an agent's own app, that app's settings apply. A hosted Ensemble runs Claude Code, Codex, Cursor and GitHub Copilot tasks only on a paired computer.
Paths below are under apps/hub-api/src/ unless qualified.
From the terminal. The ensemble app (26) starts the same runs: /run <what to do> creates a task and assigns it to the agent chosen with /agent, in the folder the app was opened in, under the profile chosen with /profile (runRequest in apps/cli/src/tui/model.ts).
Chat with your own agent. With Claude Code, Codex, Cursor's agent, GitHub Copilot, Grok Build, OpenCode, Gemini CLI, Antigravity, Mistral Vibe, Qwen Code or Kimi Code chosen, the app's Chat tab is that agent. Each message is a run with chat: true (Assign.chat, kept on the job's context; refused for Ensemble's agent), so every run rule applies.
- The first message says where the agent is (the terminal chat, the folder), the ensemble MCP tools and how the run works (
engineRules). Later messages continue the run (continueFromJobId) and resume the agent's session from itsengineevent (claude -p … --resume <session>, Codexthread/resume, Cursor's and Copilot'ssession/load, whose replayed history is not part of the reply), and are sent as typed; the rules go again only when the person changed the run's settings since the last message (chat_rulesevent,rulesHash;chatPromptinworkspace/agent.ts). - If the session cannot be reopened (
SessionGoneError: deleted, or made on another computer) or the agent changes mid-chat, the message runs in a new session with the previous turn's summary. - An assistant or node conversation on an AI plan (§8) whose session was deleted in the agent goes on in a new session that gets the instructions and the conversation so far. Copilot's
--session-idwould quietly start an empty session under the deleted one's id, so its session is looked up in Copilot's ownsession-statefirst (copilotHasSession); the other agents refuse to reopen one they no longer have. - The chat's task stays In progress and gets no Agent report per turn.
- The run's MCP server (
scripts/ensemble-ask-mcp.mjs,ENSEMBLE_CHAT=1) addsensemble_list_tasks,ensemble_create_task(owner "me"; text goes on the task's page) andensemble_search(notes, pages, tasks, meetings) toask_person, answered byPOST /api/engine/toolon loopback with the run's secret, chat runs only (workspace/engines/chat-tools.ts). The judge passes them without a prompt, as they touch only the person's own board.
Agent sessions (workspace/sessions.ts). A session is one conversation with a coding agent (Claude Code, Codex, Cursor's agent, GitHub Copilot, Grok Build, OpenCode, Gemini CLI, Antigravity), carried across the runs that continue each other (continueFromJobId) with the same agent. It is derived from the runs, not stored: its id is its first run's id, its agent session is the newest engine event in it, and the chain's resource key keeps it to one turn at a time.
- Continuing a task in a session (Assign's "Continue an earlier task", the assistant's
session,Assign.session) resumes the agent's own conversation (earlierSessionwalks back to the nearest recorded session) and sends the new task with a line saying it continues there (sessionTaskPrompt); the agent keeps what it learned and its prompt cache. A session that cannot be reopened (SessionGoneError) runs as a new one with the full prompt. - A session that no longer exists. The person may delete a session in the agent's own app, or the agent may expire it (Claude Code after 30 days). Every agent says so when asked to reopen it (Claude Code: "No conversation found", exit 1; Codex: "no rollout found for thread id"; Copilot, Cursor and the ACP agents:
session/loadrefused), and the run goes on in a new session in the same folder with the full brief, which has the earlier task's title, outcome and summary ("This continues an earlier task"). Every agent keeps a session's id when it resumes it, so the web tells a resumed run from a restarted one by the ids computers already send (sessionRestartOfinworkspace/session-continuity.ts): the task page says the earlier session no longer existed, the run'sSessionChoice.outcome.resumedis false, and a node is told in its run note that the agent did not remember the earlier conversation. - A session deleted in Ensemble (by the person, a node, or the clean-up) is not reopened at all (
sessionToResume): a run continuing one of its runs ("Continue an earlier task" lists them as "its agent session was deleted: a new one starts there") works in that folder in a session of its own (continuesSessionOfinworkspace/sessions.ts), which is listed, chosen and cleaned up like any other; the deleted session keeps only its own runs. - A run that continues a run in a folder the person granted (a shared folder) works in that folder however long the chain, since only the first run carries the grant (
grantedFolderOfinworkspace/agent.ts). Assign.session(any run id in it) runs on the session's computer, under its current pairing, in its folder, with its agent, model, thinking level, context window and fast mode unless others are named (a run profile's do not apply); another agent or computer is refused. Each setting is kept, reset to the agent's own default ("default", orfast: false), or set as named, separately (sessionChoicesinworkspace/assign.ts): naming a model keeps the thinking level and context window. The task's model tier adds nothing to a run in a session.GET /api/agent/sessions?taskId=&deviceId=&q=&limit=lists the person's sessions, newest first, each with agent, model, computer, folder, first and latest task, a one-line summary of the latest report, status (working,waiting,queued,idle) and whether it can be resumed;GET /api/agent/sessions/:idreads one, and a job's detail carries itssession. The web shows no session list.- Ensemble Node's nodes start and drive sessions, each node only its own (35 §8). A steer is a run that continues the one it interrupts, with
context.steer {update}(the claim spec'ssteeron a paired computer); resuming the session, the agent gets only the change (steerPrompt), and with the session gone it gets the whole brief with the update in its instructions.
Agent projects (workspace/agent-projects.ts). The person's own agents keep their sessions by working folder (Claude Code's projects, Codex's and Cursor's workspaces, Copilot's sessions by folder), so an Ensemble project gets one folder for its tasks: <workspace>/projects/<name>. A folder alone is not a project in every agent's app, so Ensemble also files its sessions there itself (below, In the agents' own apps). A task in a project that the person's own agent does, and that would otherwise get a new folder (no earlier task continued, no folder, repository, chat or Ensemble Node plan), works there, and the agent's app lists it with the project's other work. The folder is a git repository like a run's own; runs in it take turns (the dir: resource key, and the queue's same-project rule). Session lists (the Sessions page, Continue an earlier task, ensemble sessions) show such a session's folder as projects/<name>, from the folder its computer reported for the run (the prepared event); a run in a folder of its own shows "its own checkout".
In the agents' own apps (workspace/engines/app-sessions.ts). An agent names a session after its first message, which for Ensemble is its instructions, and its desktop app lists only what it knows. So, best effort and never failing a turn:
| App | Titles | Ensemble projects | What stays out of reach |
|---|---|---|---|
| ChatGPT app (Codex) | thread/name/set: a chat's title, an ask's page (Ask Ensemble: <page>), a task's title |
A Codex app-server project (project/create), found again by metadata.ensembleProjectId or by its folder and renamed with the Ensemble project; a task's new thread starts in it (thread/start projectId), as do an Ensemble Node's chats in its folder. The app's sidebar shows only the projects in its own file, .codex-global-state.json in Codex's home (CODEX_HOME, else ~/.codex), which it reads when it starts and writes back whole while it runs. So the project goes in that file under its app-server id (local-projects, last in project-order), with the thread and the project's earlier threads (thread-project-assignments), only while the app is closed: no live single-instance lock in the app's data folder (SingletonLock on macOS and Linux, lockfile on Windows) and, on macOS and Windows, no app process (codexAppOpen, fileCodexAppProject). Until then it is owed in <state>/codex-app-projects.json, tried again every 2 minutes and when the runtime starts, and shows when the app is next opened. A thread already in a project or set apart as projectless stays where it is; the person's own project for the folder keeps its entry and gets the threads; a project renamed in the app keeps the person's name. A project removed in the app (waiting to be deleted, or gone after Ensemble filed it) is left removed: the thread starts outside it. Deleting the Ensemble project removes its Codex project once the project has been in Trash 10 minutes, so Undo finds it as it was and a project back by then keeps it (workspace/project-delete.ts, frame project.delete: 25 §4.9). The computer finds the Codex projects carrying the project's id, never the person's own for the folder, and takes each out of the sidebar file the way the app does when the person removes one (local-projects, project-order, pinned-project-ids; its threads move from thread-project-assignments to projectless-thread-ids), only while the app is closed and owed until then like a filing. Codex deletes it only once the file no longer shows it (project/delete, which leaves its threads in no project), since the app imports again, when it starts, an entry whose project Codex no longer has. It is never filed again (removeCodexAppProjects, remote/project-delete.ts), and the project's folder and the agents' sessions stay. Codex keeps an idempotency key with the project it deleted. A project brought back from Trash whose Codex project Ensemble deleted (deletedFor in <state>/codex-app-projects.json) gets a new one under the next key (ensemble-project:<id>:2, …) with its next task; one the person removed in the app is not made again. Chats and asks stay outside any project |
Codex before its experimental project API: threads in no project. The sidebar file is the app's own, not an interface it offers: an app that keeps its projects elsewhere does not show what Ensemble wrote there |
| Claude app (Code tab) | Claude Code's --name on a new session (from a Claude Code whose help lists it) |
The Code tab lists only sessions it has a record for, in the profile the app runs on: Claude (signed in with a Claude account) or Claude-3p (on a gateway or a cloud provider), in ~/Library/Application Support (macOS), %APPDATA%, %LOCALAPPDATA% for Claude-3p, and a Microsoft Store build's own copies (Windows), ~/.config (Linux), each with claude-code-sessions/<account>/<org>/. A new session of Ensemble's, a chat's or a task's (in its project folder), gets the record the app keeps for a Claude Code session it imported (local_<session>.json with the transcript's id, folder and title, adoptedFromOtherSurface and fromCliTranscript; claudeCodeTabRecord), in the profile the app ran on last and the list of the account and organization it wrote to last (claudeCodeTabFolder); a Claude-3p profile whose Code tab was never opened gets it under the app's install id (ant-did) and its local organization. Ensemble deleting the session removes its records from every profile. Nothing is added for a Claude account whose Code tab was never used. The app reads its list when it starts, so a running app shows a new record once reopened |
The chat side's Projects are claude.ai's, which no program on the computer can make |
| GitHub Copilot (CLI, VS Code's sessions) | --name on a new chat session (user_named) |
Sessions by folder, as before | A task run's session (ACP) is named by its prompt, which starts with the task's title |
| Cursor | ACP sessions (~/.cursor/acp-sessions) are in neither the Cursor app's history nor agent --resume: Cursor keeps the three apart |
||
| Antigravity | Ensemble runs Google's own agent server with its own home and sign-in, not the Antigravity app, which keeps its own history |
- The project's brief (name, summary, notes, project page, repositories, people and their roles, upcoming deliverables) is Markdown with a hash (
projectBrief). The web server builds it when a computer claims the run (the claim'sproject, 25); a local-only computer builds it from its own database. Older computers ignore it and use a new folder as before. - Before the agent starts, the brief goes into the folder's
AGENTS.mdbetween Ensemble's markers (the rest of the file is the folder's own), withCLAUDE.mdandGEMINI.mdimporting it (@AGENTS.md), only when the hash differs from the one last written there. Those three files are kept out of the repository (.git/info/exclude). Runs in the folder can change them, so Ensemble never reads or writes them through a link: it reads a plain file only (O_NOFOLLOW) and writes a new file renamed over the old one. A continued run in the folder gets the same check (bringProjectinworkspace/agent.ts). - The run's rules say it is the project's folder, that
AGENTS.mdhas the brief (and, when the project changed since the folder's last run, to read it again), and to keep files only this task needs in a folder named for it. Codex, Copilot, Cursor and OpenCode readAGENTS.mdthemselves; headless Claude Code reads only the person's own settings in an Ensemble folder (a run could otherwise plant hooks for the next one), so its brief comes through the prompt's context and the rule, andCLAUDE.mdis for the person opening the folder in the Claude app. - Which folder is which project's, and the hash of the brief each last got, is kept in
projects/.ensemble-projects.json, beside the folders and outside any of them, so nothing a run writes in a project folder can point Ensemble at another. A renamed project keeps its folder (and its agents' sessions); one removed is made again under its name; another project with the same name gets its short id after the name. Opening a project's task in the agent's own app (§4) opens the project's folder too. - Off with Settings → Agents → Agent sessions → One folder per project (
orchestration.projectFolders). Apps' own "Projects" (claude.ai, ChatGPT) have no API an agent run can use; the working folder is what the coding agents group by.
Choosing a session (workspace/session-choice/). A new run by the person's own agent that would start a new session in a new folder (no session, continueFromJobId, folder, repository, pull request or chat; not an Ensemble Node plan or a retry) may continue one of the agent's sessions that fits the task. No model takes part: the same records always give the same decision, in milliseconds.
- Where. On the computer that runs the agent, when it takes the job (
createJobin its runtime,mayChoose): its sessions and the text of its earlier runs are there. Ensemble on the web sends with each claim what the computer cannot know (related: the task's project, deliverable, meeting, parent page, labels and the tasks it names,factsOf;sessionChoice: the config, with the person's setting inenabled;newSession). A local-only computer has the records itself. The computer keeps each claimed task's facts on its run (context.taskFacts), so later runs are matched against it. A run claimed before 0.7.4 has none and is never a candidate. - Candidates. This computer's sessions of the same agent that are idle, resumable, not chats, not being deleted, active in the last
limits.maxAgeDays(30, as agents keep them), at mostlimits.candidates(20), newest first, and whose every task is of the same space and account as the new task (the web space a claim came from and whom it runs for there,related.spaceandrelated.runner; this computer's own tasks are a space of their own) and in its project, or all in none: sessions never cross spaces or projects. - Signals (
Signalsinscore.ts), for each candidate against the new task:- link: the task names a task of the session: an @mention on its page, a link in its text or the run's instructions (
ensemble://task/<id>,/tasks/<id>), or a dependency; - deliverable, meeting, parent page: the same one as a task of the session;
- anchors: exact things both name (
anchorsOfintext.ts): file paths (a file shared by its path is not counted again by its name), code names (rankResults,auth_session,SessionStore), issue and pull request numbers (#123,ABC-42), web addresses. The session's side includes the files its runs changed. An anchor in more thanlimits.anchorMaxDocShare(20%) of the person's tasks, shared with more than half of the candidates, or a file every repository has (package.json,README.md,index.ts…) does not count; - topic (0–1): the cosine of TF-IDF vectors. Words are lower-cased, common English words and
text.extraStopwordsdropped, reduced to their stem by Porter's algorithm (ranking, ranked, ranks → rank), and each two neighbours also count as a phrase (1.5×). A term weighs (1 + ln count) × ln((N+1)/(df+1)) over the person's lastlimits.corpusTasks(2,000) tasks on this computer, so words in nearly every task weigh about nothing. The title counts 3×, labels 2×, the page, description, run reports and instructions 1×. It counts only with at leastlimits.topicMinShared(2) shared terms that are not common, and proportionally less while the person has fewer thanlimits.minCorpus(50) tasks; - follow-up or new work: the new task's title (
text.followUp,text.newWorkpatterns) or labels (text.followUpLabels); - warm: the session was active in the last
warmMinutes(60), so the provider's prompt cache is likely still there. Not a cut-off: a session days old is continued as readily, at the cost of the agent reading its conversation once uncached; - fill: how much of its context window the session uses, from its agent's last report (
engine_contextused/size: Claude Code's and Codex's last request, Copilot's and the ACP agents' own figure), and tasks in it.
- link: the task names a task of the session: an @mention on its page, a link in its text or the run's instructions (
- Score (
scoreCandidate): the sum of weight × signal. Defaults: link 10, deliverable 3, meeting 3, parent page 2, 3 per anchor up to 6, topic × 6, follow-up +1, new work −1 (only when no link or anchor ties it), warm +1, filling more thansize.soft(40%) −2, unknown fill and more thansize.longChainTasks(8) tasks −2. More thansize.hard(70%) full, or unknown fill and more thansize.hardChainTasks(15) tasks, is too full to continue: each more task re-sends the whole conversation on every request, cached or not, and near its window the agent compacts it and loses detail. - Bands (
decide): the best score (then the most recently active) at or abovebands.confident(6) is continued, silently: the run's card on the task page says which session and why ("Continued the agent's session: it mentions “Build the ranking API”"). When the best confident ones are too full, a new session gets the best one's last report in its instructions instead. At or abovebands.unsure(3), and below 3, a new session, recorded with its band so near misses can be studied. Nothing asks the person: Assign → More options → Always start a new agent session (Assign.newSession, the assistant'snewSession) skips it for a run, and Settings → Agents → Agent sessions → Continue a fitting agent session (orchestration.linkedSessions) turns it off. - Cost. Bounded by the limits above: the rarity table is built from titles, labels and the first part of descriptions and pages, kept per account for
limits.corpusTtlMinutes(60), and word matching stops atlimits.timeBudgetMs(3 s), after which links and structure decide alone. A decision with 20 candidates takes tens to a few hundred milliseconds. - Tuning (
config.ts,SessionChoiceConfig). Every number and word list above is one versioned config with its defaults in code. On the web server,ENSEMBLE_SESSION_CHOICE_CONFIGnames a JSON file with only the fields to change (for example{"version": "2026-12-01.1", "weights": {"topic": 8}, "bands": {"confident": 7}}); it is checked, read again within a minute of a change, and sent with every claim, so computers decide with it from their next run without a release. An invalid file is logged and the defaults apply. A local-only computer reads the same variable on itself. Changeversionwith every change. - Records (
SessionChoice, tablesession_choices). Every decision: the run, task, agent, computer and project,decision(continue or new),band, the session continued or the best candidate, its score and reason,note(new-session: asked for;too-full;unavailable: the session could not take the run;not-linked: the web refused the session the computer named),configVersionandconfigHash, and every candidate'ssignals,partsand score, best first. When the run ends,outcome: its status, duration, tokens, and for a continued session whether the agent really resumed it (the agent's session id stayed the same). A computer keeps the record and sends it to the web in the web's ids (POST /api/devices/self/jobs/:id/session-choice, under the job's lease), which links the run to the session it continued (only one of the same account and agent on that computer). Kept for the history window (historyRetentionDays). To calibrate, read the rows (bands near the thresholds,note = 'new-session',outcome->>'resumed') and score their storedsignalsagain under other weights withscoreCandidate: no text is needed.
Cleaning up sessions (workspace/session-cleanup.ts). The scheduler sweeps each account every 15 minutes (orchestration.cleanSessions, on by default): a session goes from its agent (deleteAgentSession, ledgered as workspace.session.delete by system with reason: "retired") once every task in it is retired: deleted at least 10 minutes ago (so Undo still finds it), or done or dropped longer than the completed window and not pinned. A session with a run still queued or going waits for the next sweep; one with a task still in use is looked at again after 6 hours, and one on a computer that is offline or too old to delete sessions after an hour (context.sessionCleanup.nextAt on its runs), so it never holds up the sessions behind it. A failed deletion is tried again a day later. Each sweep reads a session once, however many of its runs match, looks at no more than 60 and deletes at most 20. Just before anything changes, under the session's lock, the deletion checks again that the session has the same runs, none of them going, and every task still retired (guard of deleteAgentSession); otherwise it leaves the session alone. Ensemble's run records, project folders and files stay. A computer deletes the sessions of its own local tasks itself and leaves those of runs claimed from the web to the web. An assistant chat's agent session that has gone unused for 30 days is deleted from the agent too, with its folder (subscriptions/turn-sessions.ts). A coding session an Ensemble Node started for itself (its task's sourceRef is node:<id>, which stays open) goes once none of its runs started or finished in 7 days (NODE_SESSION_IDLE_DAYS, ENSEMBLE_NODE_SESSION_IDLE_DAYS), with the same lock and checks; a person's own open task still keeps its session.
Ensemble Code. With Ensemble's agent chosen, the Chat tab is Ensemble Code: the assistant with a code session (assistant/tools/code.ts). It reads, lists and searches the folder and reads PDF and Office files as text. Its one write, ensemble_start_agent, creates a task and starts the chosen engine on it with the profile's settings (or in a fresh clone when given a repository URL) through createJob, so every run rule applies. It follows the assistant's write policy (11 §6); applied from a proposal it is ledgered as assistant.ensemble_start_agent. Ensemble Code never edits files or runs commands itself, and the hosted API refuses it (409): it needs the runtime on the person's computer.
1. Claude Code as the engine
Account boundary. Ensemble starts the person's own unmodified claude with its own sign-in: the claude command on the trusted PATH (never inside the work folder; the PATH comes from the login shell and usual install folders such as ~/.local/bin), else the Claude app's copy (§8; claudeBinary in subscriptions/detect.ts). Ensemble never offers a Claude login, reads Claude credentials or runs Claude Code on its servers; API-key variables are removed (§6).
A run (workspace/engines/claude-code.ts):
- Ensemble prepares the folder, branch and trust as for its own agent.
- It starts
claude -p <task> --output-format stream-json --verbose --permission-mode default --setting-sources <sources> --settings <per-run JSON> --mcp-config <per-run JSON> --max-turns <limit> [--model <model>] [--effort <level>]in the work folder. Model, thinking level and context window go throughclaudeLaunch(packages/shared-types/src/claude-models.ts): the long window is the model id with[1m](claude-sonnet-4-6[1m]), and each level is sent under the name that model takes (Opus 4.7's Extra high ismax). A level or window the model does not take is left out with anengine_noteon the run.<sources>isuser,project,localin a folder the person trusted themselves, by name (a saved trust rule for that very folder, for good or for this task;exactTrust,engineTrustinengines/registry.ts), so the repository's own.claude/settings and hooks load as in their own Claude Code. Anywhere else, including folders inside a trusted one, the workspace and fresh clones, it isuser, and they do not run. - The per-run settings add a PreToolUse hook on every tool (
scripts/ensemble-hook.mjs --engine). It sends the call toPOST /api/engine/askon this computer with the run's secret, and Claude Code waits for the answer from the judge (workspace/engines/judge.ts):- Inside the folder. Edits and commands follow the mode: allowed in Review only and Run unattended; asked on Needs me in Ask unless the folder is trusted. A Bash command that only reads files in the folder runs without asking in every mode, as the person's own Claude Code, Codex and Copilot run it (
quietCommandinengines/judge.ts;quietReadandquietArgsinworkspace/tools.ts, also used by Ensemble's agent). Every program in it reads only (ls,cat,grep,rg,findwithout-exec/-delete/-fprint,wc, read-onlygit status|log|diff|show…); every name it reads, followed through links, and every match of its globs is in the folder; it runs in the folder, redirects only to device files, and has noVAR=valueorenvin front. Still following the mode: variables,$(…), backticks and brace lists; pagers and tools that read a project config that can set one (less,bat,ack);sedandcurl; options that start a program, write a file or follow links (rg --pre,fd -x,sort -o,git diff --output,grep -R,diff -r); git options before the subcommand; PowerShell and Windows. Read-only git also needs the sandbox and a repository config without a pager, diff or filter driver, fsmonitor or include. - Outside the folder. The zones and
outsideAccessrules of 24 §3 decide, for Ensemble's agent and every engine (outsidePathinworkspace/tools.ts): reading goes ahead; Run unattended changes other folders in the workspace and the person's own folders; Review only and Ask ask once per folder before changing it, then work there freely for the run. A shell command is read the way a shell reads it (workspace/shell-parse.ts): quotes first,cdsets the folder later relative paths resolve in, redirects (>,>>) are writes, and code passed with-e,-cor--eval, a search pattern, a URL or/dev/nullis not a path; a program writes unless it only reads (readsOnly). Claude Code's sandbox gets matching paths (claudeFilesystem,engineSandboxPaths). Keys, sign-ins and browser data (SECRET_PATHSinworkspace/guard.ts), another account's files, Ensemble's own data and its git helper are always refused; start-up files (.bashrc,.zshrc,.gitconfig, launch agents), system folders and Ensemble's own code are refused for writes (outsideRefusal). The agents' own skill folders (§10) are read in every mode, and a skill's script run there is a read of that folder, not a change (skillScriptinengines/skills.ts); a write there is judged like any outside folder. With No access, Claude Code's sandbox opens them for reading too. - Git and GitHub have no limits of Ensemble's own (
checkGit): with No limits the engine commits, makes and switches branches, pushes any branch (force and deletes included) and opens, comments on or merges pull requests withgh, using the computer's own sign-in, approved like any command by the run's mode. Only the person's choices stop it: Local only refuses commits, merges, rebases and the like; Commit refuses pushes; "Ask me before each commit" asks each time; internet off refuses network git andgh. With a pull request plan, Ensemble also pushes, opens and merges it as the task says, for every engine (24 §5.1). With Local only, a commit made some other way (a script) is undone at the end; the files stay. git clonewith the sandbox on is done by Ensemble. Claude Code's sandbox blocks writes to.git/configand.git/hooksunder the run folder (so a clone there fails), and loosening that would let a script plant a hook that runs unsandboxed later. Forgit clone <https URL> [folder]alone or first in a chain, the judge clones itself (cloneForAgentinworkspace/tools.ts, rules inworkspace/clone.ts) and tells Claude Code where the repository is. Only a publichttpsURL without user name, query or port; no credentials or hooks (--template=;file://refused); into a new folder in the run folder, under Ensemble's sandbox with internet on. Kept flags:--depth,--branch,--single-branch,--filter,--no-tags,--quiet. The clone's.git/configand hooks stay read-only. A clone after other commands (cd x && git clone …) is refused with the reason. Files that can run code later (folderOpentask,.envrc, husky hooks, install scripts, Makefile) are flagged in the answer and run summary. With the sandbox off, Claude Code's git runs as usual. Ensemble's agent uses the same function; Codex and Cursor keep their own handling (their approval answers carry no message). A task can also name a repository at assignment (repoUrl), cloned as the run folder.- Privilege, persistence, keychain and machine-control programs (
sudo,crontab,launchctl,security,osascript,pkill,shutdown, …;isNeverProgram) are refused. Programs that reach another computer (ssh,scp,sftp,nc;isRemoteProgram) need the internet and ask in Ask and Review only. Developer tools such asdocker,nohup,tmuxandkill <pid>follow the mode. With internet off, web tools are refused. - A refusal goes back to Claude Code with the reason, and it carries on.
- Inside the folder. Edits and commands follow the mode: allowed in Review only and Run unattended; asked on Needs me in Ask unless the folder is trusted. A Bash command that only reads files in the folder runs without asking in every mode, as the person's own Claude Code, Codex and Copilot run it (
- Questions. Headless Claude Code has no question tool, so the run adds Ensemble's
ask_person(MCP serverscripts/ensemble-ask-mcp.mjs). It shows the question on Needs me with options, the recommended one and a free-text box; the call waits for the answer (app, web, phone orensemble needs). Each engine gives this server a day-long tool timeout, as long as the card lasts (Claude CodeMCP_TOOL_TIMEOUT, Codextool_timeout_sec, Copilot the server'stimeout: its default is 180 s, after which the agent got "Request timed out" and gave up). While it waits the server sends MCP progress notifications when the call carries a progress token. If the agent still stops waiting (notifications/cancelled), the server hangs up and the card is withdrawn (expired), here and on the web (/api/devices/self/decisions/:id/closewithexpired), since an answer would reach nobody. In Run unattended it returns the recommended option at once, and every such decision is listed on the task. A task run's server also hasreport_resultandattach_evidence(§9). Agents that ask their own questions are asked the same way: Claude Code'sAskUserQuestion, Codex'sitem/tool/requestUserInput, Cursor'scursor/ask_question, and a form an agent or an MCP server inside it sends (ACP'selicitation/create, OpenCode's question tool; an MCP server's form through Codex): each field is one question with its choices (answerForminengines/elicitation.ts), unattended runs take the field's default or first choice, and a form asking for a password, key or token is declined. - Sandbox. With the sandbox on, Claude Code's own sandbox (Anthropic's sandbox-runtime: Seatbelt on macOS, bubblewrap on Linux) confines shell commands to the work folder, the cache and the folders the run was let into (Run unattended: also the workspace's folders and the person's own folders). A command it stops may be retried outside the sandbox (
dangerouslyDisableSandbox); the hook judges that retry: allowed in Run unattended, in Review only when its outside paths are all in allowed folders, otherwise asked. Where it cannot run (native Windows; Linux without bubblewrap and socat) Claude Code does not start; Ensemble says so and the sandbox can be turned off for the run. Internet off is an empty domain list. - Progress and trace. The stream (
system/init,assistant,user,result) feeds progress, live log, tool count, tokens and reported cost, and the run's trace (EngineTrace,workspace/engines/trace.ts): thinking, messages, and each tool call as one step (name, summary, input, then output and duration from itstool_result; failed if marked an error), in the engine's order. Previews only (TRACE_PREVIEW): input 600 characters, output 1,200, thinking and messages 2,000, final answer 20,000; at most 600 steps. Secrets are redacted by shape (lib/redact.ts), and a call reading a secrets file (.env*, private keys,.npmrc,.netrc, credentials files) gets no output. Open calls end when the run stops or fails. The Runs page shows the trace (02); a paired computer sends the same previews to the web (25). The final reply is the task summary; model, cost estimate and changed files (names, line counts) are kept on the job (evidence).
A task assigned on the web with Claude Code runs the same way on the paired computer; its Needs me cards reach the web and phone through it (25). Models: the ones Claude Code itself lists for the plan (§8: its initialize reply, each with its thinking levels and whether it has fast mode), with each model's context window and default level from CLAUDE_MODELS (claude-models.ts: a fixed 1M or 200K, or 200K with a 1M choice; aliases opus, sonnet, haiku and fable follow the newest of each), then the earlier models that version still runs (minVersion). Before the computer reports, the table stands in. The table follows T3 Code's model manifest (MIT). Fast mode: the per-run settings carry fastMode: true for a model that has it; one without it runs at its usual speed with an engine_note.
2. Codex as the engine
Account boundary. Ensemble starts the person's own Codex through codex app-server (JSON lines over stdio, workspace/engines/codex.ts): codex on the trusted PATH, else the ChatGPT app's copy (/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex on macOS, under %LOCALAPPDATA%\OpenAI\Codex\bin\ on Windows, /usr/lib/chatgpt/resources/codex on Linux), which shares the ~/.codex sign-in. Ensemble never signs in for the person or reads auth.json or the keychain, and refuses to start when account/read says Codex is not signed in ("Open the ChatGPT app (or run codex login) and sign in").
A run:
initialize(clientInfo.name: "ensemble"),account/read, thenthread/startin the work folder with:approvalPolicy(codexApprovalPolicy):untrusted(Codex asks before each command it does not consider safe) with the sandbox off, or in Ask mode in an untrusted folder; otherwiseon-request(asks only to leave its sandbox).sandbox:workspace-writewith the sandbox on,danger-full-accesswith it off.turn/startpassessandboxPolicy.workspaceWritewith the work folder and cache as writable roots (plus home folders with Read and write outside access; never a folder another run is changing, 24 "Two runs, one folder") and the run's internet setting. Codex's own sandbox (Seatbelt, bubblewrap, its Windows sandbox) confines it; Ensemble does not nest its own.config.projects[<folder>].trust_levelfor this thread:"trusted"in a folder the person trusted by name (engineTrust, as for Claude Code), so the folder's.codex/hooks, rules and MCP servers run as in their own Codex;"untrusted"anywhere else, so they do not.config.mcp_servers.ensemble: the sameask_personserver with the run's secret.turn/startcarries the run's thinking level aseffort, checked as for Claude Code, and with fast mode the model's fast service tier asserviceTier(frommodel/list: the tier named Fast,priority, else thefastspeed tier;codexFastTier).
- Codex's requests are answered by the same judge (
answerCodex):item/commandExecution/requestApproval: the command (or network host) is judged, in the folder Codex says it runs it in (cwd);acceptordecline.item/fileChange/requestApproval: the files from itsitem/startedare judged as writes; a change whose files Ensemble did not see is declined.item/permissions/requestApproval: internet and folders are granted only as far as the run allows, for that turn.item/tool/requestUserInput: each question goes to Needs me (the recommended option in Run unattended).mcpServer/elicitation/request: Ensemble's own server never asks; another MCP server's form with fields is the person's question (§1 step 4), and one without fields (approving one of its tools) is judged as that server's tool.- Sign-in token refreshes, attestation and unknown requests are never handled.
item/*notifications feed the live log and tool count,thread/tokenUsage/updatedthe tokens; the final agent message is the summary. Notifications are handled concurrently;turn/completedends the turn after every earlier one is handled (at most 10 s more), so the final message and the last tool calls are in. The trace (§1 step 6): each command, file change and MCP call is a step fromitem/startedtoitem/completed(output, exit code, time), reasoning items are thinking, earlier agent messages are messages. Stop sendsturn/interrupt, then ends the process.
Tests: engines/codex.test.ts, engines/codex.e2e.test.ts (with a stand-in codex).
3. Cursor's agent and GitHub Copilot as the engine
Both are driven through ACP, the Agent Client Protocol (JSON-RPC lines over stdio). Every session/request_permission is read as a tool call and judged by the same rules as §1; the answer is allow-once or reject-once, never allow-always, which would change the person's own allowlist in that tool. session/update feeds the live log, tool count, the reply (the task summary) and the trace: tool_call to its last tool_call_update is one step; agent_thought_chunk is thinking. Stop sends session/cancel. A chat message continues the agent's session with session/load.
Cursor's agent. Ensemble starts the person's own cursor-agent (on the trusted PATH): cursor-agent --sandbox enabled|disabled --workspace <folder> acp (workspace/engines/cursor.ts). It uses the existing login (authenticate with cursor_login); without one it stops with "Run agent login there". A run: initialize, authenticate, session/new in the work folder with the ask_person MCP server, then session/prompt with the task. Cursor's own sandbox is on when the run's is. cursorCall reads a command as an execute call titled with the command, an edit by its path, and an MCP tool by its server: tool title (Ensemble's own tools pass; its browser and capture tools follow the mode). cursor/ask_question and form questions (elicitation/create) go to Needs me (recommended option in Run unattended; a typed answer goes back verbatim). Tests: engines/cursor.test.ts, engines/cursor.e2e.test.ts.
GitHub Copilot. Ensemble starts the person's own copilot (on the trusted PATH) with the sign-in it already has (workspace/engines/copilot.ts, subscriptions/copilot.ts): copilot --acp --no-auto-update --no-ask-user --additional-mcp-config @<file> --allow-tool 'ensemble(ask_person)' … --disallow-temp-dir [--sandbox] [--context long_context]. Copilot's ACP accepts only http MCP servers from the client, so the run's ask_person server (and Chat's board tools) comes in through a per-run config file (mode 0600, removed at the end, timeout one day so ask_person waits for the person); --allow-tool 'ensemble(<tool>)' lets Ensemble's own tools run without asking (ENSEMBLE_OWN_TOOLS in engines/judge.ts: ask_person, report_result, attach_evidence, ensemble_context, secret_http and Chat's board tools), while its browser and capture tools are asked about and judged by the run's mode like any MCP tool, and --no-ask-user sends every question through ask_person to Needs me. A session that starts with the person's own /allow-all on, or in autopilot or plan mode, is set back to allow_all: off and the agent mode before the task goes (askingCopilot): left on, Copilot would ask nothing and the judge would see no call. One that cannot be set back is not run.
- Model, thinking and context. The task's model, thinking level and context window are given as Copilot starts (
--model,--reasoning-effort, and--context long_contextfor the long window:contextTieron the assignment, kept on the job's context and sent to a paired computer ascontextTier). Switching the model over ACP afterwards puts the session on that model's standard window, sosession/set_config_option(model,reasoning_effort) is used only when Copilot did not start on them, and the run then notes that the window may be the standard one. A model the plan does not offer stops the run with that sentence; a level the model does not take is skipped with a note. A run whose reported window is smaller than the long window chosen gets a note saying so. Copilot'susage_update(tokens in use, window size) is recorded on the run asengine_context; tokens come from eachsession/promptresult. - Permissions.
copilotCallsreads anexecuterequest by itscommand, aneditbyrawInput.fileNameand everylocationspath (a patch over several files is judged for each),readandsearchby path,fetchby URL, and anything else as an MCP tool named by the<server>-<tool>title of itstool_callupdate. Reads inside the work folder do not ask; reads outside it, edits, commands, fetches and MCP tools do. - Refusals. Copilot ends its turn when a call is refused. The reasons go back as the next message ("Ensemble refused these tool calls … Continue the task another way within the rules, or finish and say what is left"), at most
REFUSAL_FOLLOW_UPS(6) times; Copilot's "Operation cancelled by user" note is left out of the reply. - Attachments. The task's originals are copied as for the other engines (32) and also sent with the first message: images inline (up to 3 MB, when the model takes images), other files as
resource_links. - Sandbox. With the run's sandbox on, Copilot's own command sandbox (
--sandbox: Seatbelt on macOS; bubblewrap with a private network on Linux, which needsbwrap,slirp4netns,unshare,nsenter,iptablesand/dev/net/tun) confines its shell commands. Where it cannot run, the run stops with what is missing; with the sandbox off, Ensemble still judges every call Copilot asks about. - Sign-in. Without one, the run stops with "Run
copilot loginthere (or press Sign in under Settings → Models)". Copilot answers "Authentication required" both when signed out and when the system keychain holding its sign-in cannot be read (locked, or not ready right after a restart). When itsconfig.jsonlists a signed-in account (copilotSavedSignIn, account names only), the run treats it as the keychain and tries again (§3.1); the computer's agent check reports the sign-in as unknown rather than signed out.
A paired computer advertises the engines its runtime has (capabilities.engines; runtimes without the list have Claude Code, Codex and Cursor's agent). Assigning GitHub Copilot to a computer whose Ensemble is older is refused with "update it on that computer".
3.1 Keeping a run going
Every engine runs under a watch (workspace/engine-watch.ts, used by executeJob in workspace/agent.ts):
- Stuck. Quiet for 10 minutes (no output from the engine's process, no tool call running, no question open on Needs me) counts as stuck; one tool call may run quietly for 60 minutes. The engine is stopped and its session carried on.
- Failures that pass. The network or the provider's servers, the engine's process dying, or a locked keychain after a restart: the run waits 15 s, 1 min, then 2 min and carries the same session on (
session/load,--resume, the Codex thread) with a message saying why it stopped and to check the files before going on. After 3 tries it ends with the engine's own error. Signed out, not installed, an unknown model and limits are not retried. - Minutes. The run's minutes limit (time on Needs me excluded) ends it as a limit, work kept.
Each carry-on is an engine_retry event on the run; its open tool calls end as skipped and the trace goes on.
3.2 Choosing the model, thinking level, context window and fast mode
The Assign dialog (components/agent/assign-dialog.tsx) offers the same menus for every coding agent, from the models the chosen computer reports (computerAgent and engineModel in apps/hub-web/lib/engine-models.ts): Claude Code from CLAUDE_MODELS for its installed version (Ensemble's own table before the computer reports), Codex from model/list (each model's levels, default level and whether it takes images), GitHub Copilot from models.list (cost, levels, two windows), Cursor's agent from cursor-agent models (names only), the ACP agents as §3.3 says. <Agent> default comes first; a typed model when the computer listed none. Under it are the model's facts (vendor, cost, context sizes); Thinking appears when the model has levels (its own default named, "Default (Medium)"), Context window when it has two, and Fast mode when the model has one (faster output that uses more of the plan: Claude Code's fastMode, Codex's fast service tier). A model without the chosen level or long window resets that choice, and so does changing agent. The dialog says when the agent is not signed in on that computer (with its login command), when Ensemble there is too old to run it, or what its check reported. For an agent with no OS sandbox there (engineSandboxes) and Sandbox on, it says the run will stop and to turn Sandbox off (§3.3). engineAssign sends model, reasoningEffort, contextTier and fast for every agent; the runner on the computer checks them again against that model, and one the model does not take is left out with an engine_note. Fast mode is kept on the job's context (fastOf) and sent to a paired computer as fast. Run profiles, Quick assign, Run again and the assistant's hub_assign_agent keep all four; the CLI's /model offers the same choices (26). Tests: engines/copilot.test.ts, engines/copilot.e2e.test.ts (with a stand-in copilot).
3.3 Grok Build, OpenCode, Gemini CLI, Antigravity, Mistral Vibe, Qwen Code and Kimi Code
These seven run through one ACP runner (workspace/engines/acp.ts, ACP_AGENTS), each the person's own agent with its own sign-in. On Windows an agent installed with npm is a .cmd shim, which Windows cannot start without a shell: Ensemble starts Node with the script the shim names instead (hostLaunch in workspace/policy.ts, Node beside the shim when there is one), for the run, the check, the sign-in window and session deletion (AcpLaunch.base).
| Agent | Started as | Sign-in | Ensemble keeps from it |
|---|---|---|---|
| Grok Build | grok --permission-mode default agent stdio (on the command line, so a saved always-approve does not apply); authenticate with cached_token; initialize _meta.clientType: "extension" |
grok login |
XAI_API_KEY |
| OpenCode | opencode acp with OPENCODE_PERMISSION (and the same rules in OPENCODE_CONFIG_CONTENT, for an OpenCode that predates it) asking before every edit, command, fetch, subagent and outside path (OPENCODE_PERMISSIONS; reads, searches and its own bookkeeping go ahead) and OPENCODE_DISABLE_AUTOUPDATE; in a folder the person did not trust by name, OPENCODE_DISABLE_PROJECT_CONFIG, so the repository's own opencode.json, agents and plugins do not load |
opencode auth login (any provider OpenCode signs in to: Zen, Claude, ChatGPT, Copilot…) |
the API-key variables of §6 |
| Gemini CLI | gemini --acp --approval-mode default (--experimental-acp on a Gemini CLI from before the rename, read from its help) and --model |
gemini, whose first start asks how to sign in (Sign in with Google) |
GEMINI_API_KEY, GOOGLE_API_KEY, GOOGLE_GENAI_USE_VERTEXAI |
| Antigravity | Google's ACP agent server, which Ensemble installs (below), --uid= on Linux; authenticate with oauth-personal; a session that starts in another mode is set to default, the one that asks (or not started); reopens a session with session/resume |
Sign in under Settings → Models | Google API-key and cloud variables; its own GEMINI_HOME |
| Mistral Vibe | vibe-acp; a session is set to Vibe's ask agent (its default accept-edits writes without asking); a folder's own .vibe/ settings, agents and skills load only where the person trusted it in Vibe (shared trust, 24) |
vibe-acp --setup (vibe --setup): the Mistral account in the browser, or an API key |
MISTRAL_API_KEY |
| Qwen Code | qwen --acp --approval-mode default (--experimental-acp on an older one) and --model; built from Gemini CLI |
qwen, whose first start asks how to sign in (/auth: the Qwen account with a Plus or Pro plan, or a provider key) |
the API-key variables of §6 |
| Kimi Code | kimi acp (Kimi Code CLI, which replaced the archived Python Kimi CLI); authenticate with login; every session starts in default, which asks before edits and commands, and is set back to it if not; KIMI_CODE_TRUST_WORKSPACE=1 only in a folder the person trusted by name in Ensemble, removed otherwise |
kimi login (device code: a link and a code) |
MOONSHOT_API_KEY |
A run is the Copilot run of §3 with the agent's own names: initialize, authenticate where the agent has a stored sign-in, session/new in the work folder with the ask_person MCP server (stdio), then the model and thinking level from the session's own options (configOptions with category model and thought_level, set with session/set_config_option; an older agent's models and session/set_model). A model the account does not offer stops the run with that sentence; a level the model does not take is left out with an engine_note. Every session/request_permission is read by acpCalls (a command from rawInput.command as a string or argv, or a backticked title; paths from locations, a diff's path and rawInput; an MCP tool as server: tool, server-tool or mcp__server__tool) and judged as in §1, answered allow-once or reject-once, never "always" (for Grok and OpenCode "always" is project-wide). Ensemble offers form questions (clientCapabilities.elicitation.form), so an agent's elicitation/create (OpenCode's question tool) reaches the person as ask_person does (§1 step 4); without it OpenCode tells its model the question could not be shown. Refusals go back as the next message, at most REFUSAL_FOLLOW_UPS times. usage_update is recorded as engine_context. A chat message continues the session.
Sandbox. Grok Build, OpenCode, Antigravity, Mistral Vibe and Kimi Code have no OS sandbox Ensemble can turn on, and Gemini CLI and Qwen Code have one only on macOS (--sandbox, Seatbelt). A run with Sandbox on stops with "<agent> has no OS sandbox Ensemble can turn on here. Turn the sandbox off for this run: Ensemble still judges every command, edit, outside read and fetch <agent> asks to make."
Antigravity's install (subscriptions/antigravity.ts). Google publishes the agent as a zip per platform on dl.google.com (agy-acp-server-1.3.0-<os>-<arch>.zip, a few hundred MB). Sign in (or Install and sign in on its tile) downloads it once, checks it against the pinned SHA-256 and unpacks it (ditto, unzip, Python's zipfile or tar) into <state>/agents/antigravity/versions/1.3.0/. It then starts the server with GEMINI_HOME=<state>/agents/antigravity/home and AGY_ACP_FORCE_FILE_STORAGE=1, calls authenticate, and opens the Google sign-in link the server prints (Open the following link to authenticate the ACP server: …) in the browser; Google answers on the server's own 127.0.0.1 address, and the sign-in stays in Antigravity's file in that home. A run that meets the link instead says it is not signed in. Release table and environment follow T3 Code (MIT).
3.4 What the agent says it runs with
A run's settings are recorded three ways (35 §8.10): as asked, as sent, and as the agent itself reported them. Each adapter reports only what its agent says, never what was asked (workspace/engines/observed.ts): observed on its engine event, and the window it used on engine_context (size in tokens, context when the model's windows are known; within 5% of the long window is the long one).
| Agent | Model | Thinking level | Context window | Fast mode |
|---|---|---|---|---|
| Claude Code | model in its init message |
not reported | the main model's contextWindow in the result's modelUsage (a fixed 1M window counts as long) |
not reported |
| Codex | model in the thread/start or thread/resume reply |
the thread's reasoningEffort, only when the turn sets none (Codex does not echo a turn's own) |
modelContextWindow in thread/tokenUsage/updated (one window: default) |
not reported |
| GitHub Copilot | the session's model option after it starts |
the session's reasoning_effort option |
the window in usage_update, against the model's long window as the computer listed it |
— |
| Grok Build, OpenCode, Gemini CLI, Antigravity, Mistral Vibe, Qwen Code, Kimi Code | the session's model option (an older agent: the model it accepted) | the session's thought-level option | the window in usage_update, in tokens |
— |
| Cursor's agent | the session's currentModelId, when it reports one |
— | the window in usage_update, when it sends one |
— |
The computer sends the result with its progress when it changes and with complete (observed, and totals.observed; 25 §4.1), and the server keeps it on the job (workspace_jobs.observed_settings). A run on this server is read from its own events. Anything not reported shows as "not reported": a runtime before this release reports only the model, from its engine event.
4. In the agent's own app, or a terminal
Assign → More options → Open in the agent's own app opens the task in an app found on this computer (GET /api/agent/apps) on a folder: one of the person's, an earlier task's, or a new empty one in the workspace (Ensemble does not clone for an app). POST /api/agent/handoff, workspace/handoff.ts:
| App | How it opens | The person |
|---|---|---|
| Claude app (Code tab) | claude://code/new?folder=…&q=… |
Confirms the folder, presses Send |
| Codex in the ChatGPT app | codex://new?path=…&prompt=… |
Presses Send |
| Cursor | The folder in Cursor, then cursor://anysphere.cursor-deeplink/prompt?text=… |
Presses Send |
| VS Code (Copilot agent) | code -n <folder>, then code chat -r -m agent "<prompt>" |
Presses Send |
In a terminal: Claude Code, Codex (its CLI or the ChatGPT app's copy), Cursor's agent or Copilot CLI starts in a terminal window on the folder with the task as first prompt (claude "…", codex "…", cursor-agent "…", copilot -i "…") and begins at once. The window is Terminal on macOS (a .command file), the first terminal found on Linux, PowerShell on Windows (an encoded command); terminalScript quotes the prompt so nothing in it runs as a command.
Vendors let another app fill in a prompt but not send it, so in an app the person presses Send. The prompt ends with a marker (Ensemble task ens-…); the agent's UserPromptSubmit hook (or a hook in the hand-off's folder, for tools that never see the prompt) links that session to the hand-off. From then on its permission prompts and questions reach Needs me, and the task page shows "In Claude", then the agent's last words when a turn ends (Stop), until the person presses "Done with it". The app uses its own settings and sign-in; Ensemble's mode, sandbox and git choices do not apply. Only the runtime on the person's computer opens apps; the hosted site refuses with 409.
5. Prompts from the tools, and answering in either place
The same hook, installed into each tool's settings, sends prompts to Needs me: Settings → Editors & agents → Connect (desktop app or the CLI's runtime), ensemble hooks connect <tool>, or node scripts/ensemble-hook.mjs install --target cursor|claude|codex|copilot-cli|vscode --token …. Connect signs the hook in with a hooks key: it can open a card, wait on it, withdraw it and report session activity, never answer a card (hooksTokenRejected in bridge/auth.ts).
Which sessions. Only tasks Ensemble opened in the tool (§4) reach Needs me, identified by the prompt marker or an already linked session; folder proximity is not enough. Any other session gets skipped at once. orchestration.editorRequests is always ensemble (a saved all reads as ensemble). In a run Ensemble started (§1–§3), the engine channel answers and the person's own hook steps aside (ENSEMBLE_RUN).
| Tool | Asks | Reports | Answer in either place |
|---|---|---|---|
| Claude Code and the Claude app | PermissionRequest, including AskUserQuestion (answered with the questions' answers) |
PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop (in the background) |
Yes. Claude's dialog stays open beside the card; the first answer counts. Answered in Claude, the card closes ("Answered in Claude Code") when the tool runs, the turn ends or the next prompt is sent |
| Codex (CLI and ChatGPT app) | PermissionRequest |
PostToolUse, UserPromptSubmit, Stop |
Ensemble first; if nobody answers in time, Codex asks itself (its prompt cannot race the hook). Approve the hooks once in Codex (Settings → Hooks, or /hooks) |
| GitHub Copilot CLI | preToolUse |
postToolUse, userPromptSubmitted, sessionEnd |
Ensemble first, then Copilot |
| Cursor | beforeShellExecution, beforeMCPExecution |
afterShellExecution, afterMCPExecution, beforeSubmitPrompt, stop |
Ensemble first, then Cursor |
| VS Code (Copilot agent) | PreToolUse |
PostToolUse, UserPromptSubmit, Stop |
Ensemble first, then VS Code |
Reporting hooks wait at most 5 s and never block the tool. POST /api/decisions/activity links the session, closes cards answered in the tool (status settled; the waiting hook lets go) and records a hand-off's progress. An answer applies once, for the session, or always. If Ensemble is unreachable or nobody answers in time, the tool shows its own prompt; the hook never allows by itself. ensemble needs lists these too.
Answers in a run Ensemble started (§1–§3). Allow for this run (scope: "session") is kept as a rule of that run (RUN_RULE_SOURCE) and read before the person is asked again (runRule in workspace/tools.ts). It covers the same command with other arguments (commandKind in workspace/command-kind.ts: the program and its script or what it does, as python3 <script>, npm run build, git commit, npx playwright test; never inline code, a variable, VAR=value in front, another computer, or a command with an option before what it does, which match only themselves), an edit anywhere in the run's own folder, a fetch from the same site, and the run's other browser tools (or capture tools). Deny for this run covers the same. A run on a paired computer takes it from the web and the Node chat too: the computer reports capabilities.runAnswers: 1, the web takes for this run on a permission (never a question or a push) only for such a computer ("Update Ensemble on <computer> to allow this for the whole run from here. Allow once works now." otherwise), and the computer keeps the rule for its run (judgeAnswer in remote/policy.ts). The agents' own "always" is never chosen: Ensemble's run rule answers the agent's next ask at once (Claude Code's hook sees every call, so a Claude updatedPermissions session rule would add nothing), while an agent's "always" changes the person's own allowlist (Copilot keeps it per folder in ~/.copilot/permissions-config.json, Cursor in its allowlist, Grok and OpenCode for the project) and hides later calls from the judge.
6. Vendor and account boundaries
Engine adapters and subscription turns launch the person's installed, unmodified program with its own sign-in and never copy a vendor token into a hosted model API. Variables that would bill an API account instead of the plan are removed from the program's environment: ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN for Claude Code, OPENAI_API_KEY, CODEX_API_KEY, CODEX_ACCESS_TOKEN for Codex, CURSOR_API_KEY for Cursor (KEY_VARIABLES in each engine). The ACP agents (§3.3), subscription turns (§8) and detection drop all of them plus XAI_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY, GOOGLE_GENAI_USE_VERTEXAI, OPENROUTER_API_KEY, MISTRAL_API_KEY, ENSEMBLE_DESKTOP_TOKEN and ENSEMBLE_INTERNAL_TOKEN (agentEnv in subscriptions/detect.ts). GitHub Copilot, as an engine, in a subscription turn and in detection, also loses COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN (which would replace its stored sign-in and reach its shell commands) and COPILOT_ALLOW_ALL and COPILOT_ASSISTED_APPROVAL (which would approve tool calls without asking), and runs with COPILOT_AUTO_UPDATE=false (copilotEnv in subscriptions/copilot.ts). A tool the person set up with its own model provider (their Codex config.toml, Claude Code settings, Copilot's COPILOT_PROVIDER_*) runs as set up: the variable a Codex provider reads its key from (env_key) is kept even when it is one of the variables above (codexEnv in subscriptions/detect.ts). Detecting a binary does not prove a plan or model entitlement; the tool reports its own account and quota errors. Vendor terms are the operator's to review.
A runtime started inside a Claude session. Claude in the Claude app's Code tab can start the runtime itself (pnpm dev, ensemble runner start); the runtime then inherits what the app sets for that session and what the session sets for each command it runs. Engines, subscription turns, detection, and the terminals, editors and links that hand-offs (§4) and sign-ins (§8) open run without those variables (withoutHostSession in subscriptions/host-session.ts, in agentEnv, each engine's environment and launch in workspace/handoff.ts), so Claude Code does not wait for the app's sign-in ("Not logged in"), call itself the app or reach the app's messaging socket: Claude Code's marks on a session's commands (CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_MESSAGING_SOCKET, CLAUDE_CODE_MESSAGING_TOKEN, CLAUDE_PID, CLAUDE_EFFORT, AI_AGENT), the app's entry point, sign-in and account (CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST, CLAUDE_CODE_OAUTH_SCOPES, CLAUDE_CODE_SUBSCRIPTION_TYPE and the like), and the CLAUDE_CODE_HOST_*, CLAUDE_CODE_DESKTOP_*, CLAUDE_CODE_SDK_HAS_* and CLAUDE_AGENT_SDK_* families. Where the app manages the provider (CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST set: a gateway or a cloud provider), it has written its own provider over the person's, so the provider variables go too: CLAUDE_CODE_USE_*, ANTHROPIC_BASE_URL and the other base URLs, ANTHROPIC_CUSTOM_HEADERS, the provider's project, region and keys, and the model names (ANTHROPIC_DEFAULT_MODEL, ANTHROPIC_DEFAULT_*_MODEL). Claude Code then uses the person's own sign-in and settings (~/.claude/settings.json). Anywhere else the provider variables are the person's own and pass through, as do their other CLAUDE_CODE_* settings.
7. Limits
- GitHub Copilot loads the person's own Copilot setup as it would in a terminal: their plugins, skills, MCP servers and custom instructions (
AGENTS.mdand the like in the folder). With many plugins or MCP tools, each message carries a large fixed context. A tool the person already allowed "always" in Copilot for that folder (~/.copilot/permissions-config.json) runs without asking, so the judge does not see it; the same holds for a command in Cursor's own allowlist and a tool in Gemini CLI'stools.allowed. Copilot's/allow-alland autopilot are turned off for the run (§3). - An engine run cannot be watched or continued in the agent's own app, and hand-offs (§4) cannot be started on a paired computer from the web. Grok Build, OpenCode, Gemini CLI, Antigravity, Mistral Vibe, Qwen Code and Kimi Code have no hand-off.
- Grok Build, OpenCode and Gemini CLI load the person's own setup (their MCP servers, Gemini's and OpenCode's global settings and instructions).
ask_personwaits as long as the agent's own MCP request timeout allows; when the agent gives up, the card is withdrawn (§1 step 4). - The VS Code and Cursor editors' own folder-trust prompts do not reach Needs me. VS Code's Copilot sign-in is reachable only through the hand-off (§4); code runs and subscription turns use Copilot CLI's sign-in.
8. Your AI subscriptions for the assistant
The assistant (the dock, @ensemble on a page, the Ask panel) and Ensemble's quick model calls (triage, summaries, a tier's Test) can run on the person's own Claude, ChatGPT, Cursor, GitHub Copilot, Grok, OpenCode, Gemini or Antigravity plan instead of an API key. The plan's agent does the thinking on the person's computer with the vendor's sign-in; Ensemble's tools and data stay where they are. Nothing is billed through Ensemble, and Ensemble never sees the sign-in.
Choosing it. Each subscription is a model provider: claude-code, codex, cursor-agent, copilot-cli, grok-cli, opencode, gemini-cli, antigravity (SUBSCRIPTION_PROVIDERS in packages/shared-types/src/subscriptions.ts). A tier set to one (Settings → Assistant → Models → Your AI subscriptions → Use for everything, or a tier's provider menu) runs there, on the computer in models.computer, else the most recently seen online computer with that agent signed in (pickComputer in subscriptions/turn.ts). In a space shared with the person, only their own computers are used. The card shows only the plans found on that computer; Manage subscriptions (?dialog=subscriptions, subscriptions-dialog.tsx) lists every plan like Manage API keys: the found ones first, then the rest with how to add each. The plans (marks in apps/hub-web/components/connectors/provider-logo.tsx, sources in public/providers/manifest.json; Grok shows its initial). A tile with usage windows (Claude, Codex) shows a bar for each with how much is used and when it starts over (UsageBars). A signed-in Copilot tile shows Signed in as <account>, and its models show their vendor, cost and context sizes ("OpenAI · low cost · 200K or 922K context"; lib/engine-models.ts). A tier on a model that has a long window (a Copilot model, or a Claude model with a 1M choice) offers Context window: Standard or Long context, saved as the tier's context; a model or provider without one resets it to the default, as does Use for everything. The thinking menu names the model's own default level ("Automatic (Medium)").
Detection (subscriptions/detect.ts, reported in the heartbeat's capabilities.agents with assistantTurns: 3; rechecked every 10 minutes, on Check again and after a sign-in):
| Where it looks | Signed in? | Models | |
|---|---|---|---|
| Claude | claude on the PATH, else the Claude app's copy (claudeAppCopy, the newest version in any of its profiles) under Claude/claude-code/ or Claude-3p/claude-code/ in ~/Library/Application Support (macOS) or ~/.config (Linux), %APPDATA%\Claude\claude-code, %LOCALAPPDATA%\Claude-3p\claude-code or %LOCALAPPDATA%\AnthropicClaude\claude-code (Windows) |
claude auth status --json |
One short claude in the Agent SDK's control mode (--input-format stream-json, no MCP servers; claudeControl in subscriptions/claude-control.ts): initialize lists the plan's models with their thinking levels and fast mode, merged with the table's windows (claudeCodeModelsFrom); get_usage gives the plan (subscription_type) and its usage windows: 5-hour, weekly, weekly per model (Opus, Sonnet, or as Claude names them) and extra usage when on (claudeLimits). Without an answer, the table for that version (claudeCodeModels). Settings shows when an update would add the newest models (claudeUpdateNote) |
| ChatGPT | codex on the PATH, else the ChatGPT app's copy (§2) |
account/read (codexSignIn): signed in with ChatGPT (with its plan), with an API key (plan "API key"), or set up with its own model provider in config.toml (no account, requiresOpenaiAuth: false; plan "own model provider"). Only a Codex that needs an OpenAI sign-in and has none is signed out. A provider's env_key (from config/read) is taken from this computer's Ensemble's environment, else from the login shell (loginShellVariable: the desktop app and a login service do not inherit what the shell profile exports), kept in memory and given to Codex only (codexProviderKeyEnv); found in neither, the tile says to export it (on a Mac, or launchctl setenv) and Check again |
model/list with each model's thinking levels (including ultra), default level and image input; "Codex's default" when it lists none. Signed in, account/rateLimits/read gives the plan's 5-hour and weekly windows (the month on Free and Go; codexLimits), shown on the tile as bars with the time each starts over |
| Cursor | cursor-agent on the PATH |
cursor-agent status |
cursor-agent models |
| Grok | grok on the PATH |
grok models ("You are logged in"; it always exits 0) |
the same list's bullets (parseGrokModels) |
| OpenCode | opencode on the PATH |
opencode auth list (its credential count; a model list also counts) |
opencode models (provider/model) |
| Gemini | gemini on the PATH |
a session opened in an empty folder over ACP (acpProbe in subscriptions/acp-probe.ts); a sign-in error means signed out |
that session's model option, with the current model's thinking levels (acpModels) |
| Mistral Vibe, Qwen Code, Kimi Code | vibe-acp, qwen, kimi on the PATH (an npm .cmd shim on Windows read as Node and its script) |
a session opened in an empty folder over ACP (acpProbe); a sign-in error, or Vibe's "Missing API key", means signed out |
that session's model option, with the current model's thinking levels |
| Antigravity | Ensemble's own install (§3.3) | its sign-in file exists in Ensemble's Antigravity home (never read) | Gemini models with the level in the name (ANTIGRAVITY_MODELS, from T3 Code's manifest) |
| GitHub Copilot | copilot on the PATH |
auth.getStatus on its headless server (copilot --headless --stdio, the Copilot SDK's JSON-RPC), with the GitHub account it reports |
models.list there: each model's name, thinking levels and default level, context windows (the standard window and, for models priced in two tiers, the long one: contexts), whether it takes images, and its request cost (cost: the premium-request multiplier, or Copilot's price category); models the plan switched off are left out (copilotModels in subscriptions/copilot.ts). An older Copilot without the headless server: copilot help config |
Signing in. Sign in (web or desktop app) opens a terminal window on that computer running the tool's own login (claude auth login --claudeai, codex login, cursor-agent login, copilot login, grok login, opencode auth login, vibe-acp --setup, kimi login, or gemini or qwen, whose first start asks how to sign in; subscriptions/signin.ts). Antigravity has no command: Sign in installs its agent when needed and opens Google's sign-in page from it (§3.3). The tool opens the vendor's sign-in page and stores the sign-in where it always does. Ensemble checks the tool every 8 seconds for up to 6 minutes.
One turn (subscriptions/agents.ts). The turn is built as for a model key (11): the instructions plus a few lines on how it runs there (howYouRun in subscriptions/turn.ts: Ensemble's tools, all its own tools in a chat, Ask Ensemble or a routine (below), else its own web search and page reading when ownWeb is on (only for Claude Code, Codex and Copilot, OWN_WEB_AGENTS: Cursor's agent and the ACP agents get every tool of their own refused in a turn, so they read the web through hub_web_search and hub_read_web_page), and for a node's turn that the computer's browser and terminal are reached through Ensemble's tools, that real coding goes to a coding-agent session and that the conversation goes on from one message to the next), the message with its context, and the turn's tools (core and playbook tools plus hub_use_tool, which calls any of the turn's tools by name).
One chat, one session (subscriptions/turn-sessions.ts). A chat (its conversation id, the spec's session.key) is one session of the agent on the computer that runs it; a new chat is a new session. The computer keeps <state>/assistant-sessions.json (chat → agent, session id, the instructions the session was given, its folder and how many days it is kept) and a folder per chat, <state>/assistant/<key>, for the turn's files, instructions and MCP config. A turn resumes the chat's session when the agent is the same and the chat is where that session left it (session.position, the chat's messages before this one that a session took part in, matches what the session's last turn left; a turn on another model or computer moves it on); it sends only the message. A message that got no answer (its computer was offline, the turn failed before replying) and a failed exchange (the message and its error) do not count (sessionPosition in assistant/agent.ts), so a routine whose run failed while its computer slept goes on in the same session. For an agent that keeps the instructions from a session's first message (Copilot, Cursor's agent, the ACP agents), changed instructions no longer start a new session: a node's instructions change on most messages (the skills matched to the message, a new memory, the room), and before 0.7.6 nearly every node message left a new session in the person's agent. The message is led by what changed instead (instructionsUpdate: "Your instructions changed since this conversation began", the lines now part of them and the lines no longer part of them). When more than half of the lines changed, or after 80 messages in one session (ROTATE_MESSAGES), it starts a new session with the chat so far. A session replaced in the store (a new one for the same chat) is deleted from the agent at once, with its folder when the folder is the chat's own.
A session used outside Ensemble (subscriptions/session-touch.ts). The person can open an Ensemble chat's session in the agent's own app or terminal. After each turn the computer notes how many prompts the agent's own transcript of the session holds (prompts in the store: Claude Code's ~/.claude/projects/<folder>/<id>.jsonl user prompts, Copilot's user.message events in session-state/<id>/events.jsonl). When the count is higher before the next turn, someone sent it prompts of their own: the chat goes on in a new session with Ensemble's own history, so nothing said there enters the chat, and the touched session is kept unresumed (retired) and deleted with the chat or when its entry expires. Other agents, and a transcript the computer cannot read, resume as before.
Ask Ensemble: one short session per page (assistant/ask-sessions.ts). An inline ask (@ensemble in a page, task, project, deliverable or meeting notes; POST /api/pages/:kind/:id/ensemble) runs on the tier in Settings → Assistant → Ask Ensemble model (assistant.askTier, Medium unless changed; the chat's default tier is separate). When that tier is an AI plan, each page has one session per person who asks there (session.key ask-<kind>-<hash of person, kind and id>, sent without position, so the computer resumes it however many asks came before): asking again continues it. Asks on one page by one person are answered one after another in the order they were made (askInLine, taken when the request arrives; the waiting ask shows "Waiting for the answer before this one…"), never two at once and never one ahead of an earlier one. When the page's session is gone (deleted in the agent's app), the new one is given the page's earlier asks and answers of that session (askHistory, at most 8). Thirty minutes after the last answer (fixed, not a setting) the session is deleted from the person's computers the way a deleted chat's is (below): the account's pending ask sessions are kept in the preference assistant.ask-sessions and the scheduler's minute tick sweeps them, so a restart does not lose one; an ask that never reported back is cleared two hours after it began. The session is sent with keepDays: 1, so a computer that cannot delete on request yet drops it itself a day later. An ask on a surface (POST /api/ensemble/invoke: Today, a board, Needs me, Code; on the chat's default tier) follows the same rules, with one session per surface and the records it is about (anchorKey).
Deleting a chat deletes its session (assistant/delete-chat.ts, remote/assistant-session-delete.ts). DELETE /api/assistant/conversations/:id stops a reply still running, deletes the chat and its messages, then asks each of the person's computers that runs assistant turns to drop that chat's session: dropSession removes the store entry, deletes the session in the agent itself (deleteNativeSession, the same delete as above, for every subscription agent) and the chat's own folder, after any turn of that chat still running there has ended. A computer that is away, or does not confirm, is asked again when its event stream next opens, and while it is online every 5 minutes from the scheduler's minute tick (retryAssistantSessionDeletes); pending requests are kept per computer in the assistant.session-deletes preference for 30 days (the time after which a computer drops an unused session itself), and those of a computer no longer paired are dropped. On the desktop app or CLI runtime the session is dropped directly. Deleting a routine deletes its conversation the same way (dropRoutineChat in routines/service.ts), from the Routines page or the assistant; a routine's turns keep their session unused for twice the gap to the routine's next run, 30 to 90 days (routineKeepDays), so a weekly or monthly routine stays in one session.
A group of chats, one project folder. A turn may name a group (session.group {id, name}, with session.keepDays): its agent then works in the group's project folder (projectFolder in workspace/agent-projects.ts, <workspace>/projects/<name>), so the agent's own app lists the group's sessions together, as it groups sessions by folder; the turn's own files stay in the chat's folder. An Ensemble Node's turns use the node as the group ("<Name> (Ensemble Node)") and keep a session 7 days unused (§10.2). Turns of one chat run one at a time; otherwise it starts one, with the chat so far (session.history, the last 12 messages) before the message. A session the agent cannot reopen, or an agent too old to keep one, falls back to a new session or none, with the chat so far. Sessions unused for 30 days (or the chat's keepDays) are dropped and deleted from the agent. A computer before assistantTurns: 3 (ASSISTANT_TURN_SESSIONS) gets the chat so far in front of every message; a one-off completion (complete on a subscription: triage, a summary, a title) keeps no session, and leaves none in the agent: Claude Code runs it with --no-session-persistence and Codex in an ephemeral thread, while the session Copilot (named with --session-id from 1.0.78), Cursor's agent and the ACP agents save is deleted once the turn is over (dropOneOff), in the folder the turn ran in (Gemini CLI, OpenCode and Grok Build find a session by the folder it began in), which is removed after.
The agent's own tools in a chat (subscriptions/own-tools.ts, assistantTurns: 4). A chat, an Ask Ensemble or a routine (ownTools on the turn's spec; never a node's turn, which reaches the computer through Ensemble's tools so its owner approves, nor a one-off completion, whose input is a file, an email or a meeting the person did not write) keeps all of the agent's own tools: its shell, files, web search and its own built-in tools, as in the person's own agent, without asking. Ensemble decides nothing about what the request needs: the instructions (howYouRun with ownTools) say most requests need only Ensemble's tools, that a command or a file on the computer is the agent's to judge, that its folder is the chat's scratch folder (keep work in Ensemble with hub_save_file or a page, or where the person says), and that a change to code in a repository usually goes better as a task for a coding agent (hub_assign_to_agent) unless the person asks for it here. Only the hard floor refuses a call, judged from the turn's folder (judgeFloor in workspace/engines/judge.ts, the same floor a run has: the dangerous commands, privilege, persistence and system-settings programs, and outside that folder keys and sign-ins, Ensemble's own data and code, start-up items, the system's files and other accounts); a refused call comes back to the agent with its reason and the turn goes on. Each agent is asked its own way: Claude Code runs every tool through a PreToolUse hook (--settings, scripts/ensemble-hook.mjs --chat, which asks the turn's runtime at POST /api/engine/assistant/ask with the turn's secret and fails closed); Copilot CLI gets every tool, path and URL (--allow-all-tools --allow-all-paths --allow-all-urls, its built-in MCP servers on) and the same hook as a per-turn plugin (--plugin-dir, a plugin.json and hooks.json in the chat's folder: a folder's own .github/hooks load only for a new trusted session, a plugin for a resumed one too), from a Copilot whose help lists --plugin-dir; Codex runs without its sandbox and with its environment (no environments: []), asking before every command it does not consider safe and every file change, each answered by the floor (answerCodexOwnTools); Cursor's agent runs with --sandbox disabled and Cursor's and the ACP agents' permission requests are answered by the floor (answerAcpOwnTools). Each of its own calls, with its input and what it returned, and each refusal with why, goes in the person's AI activity log (OwnToolLog, 07 §1.4). A computer before assistantTurns: 4 (ASSISTANT_TURN_OWN_TOOLS) runs the turn with Ensemble's tools only, and its agent is told so (systemWithoutOwnTools). Otherwise the agent gets only Ensemble's tools:
- Claude Code:
claude -p [--input-format stream-json] --output-format stream-json --verbose --include-partial-messages --system-prompt-file <file> --tools "" --strict-mcp-config --mcp-config <file> --allowedTools mcp__ensemble --permission-mode default (with own web: --tools "WebSearch,WebFetch" --allowedTools mcp__ensemble,WebSearch,WebFetch) --setting-sources user (--session-id <id> | --resume <id> | --no-session-persistence) --max-turns 28 [--model] [--effort], message on stdin (with files, one stream-json user message), in the chat's folder. The instructions go with every turn (Claude Code does not keep them in a session). Model, level and the tier's long window go throughclaudeLaunchas for a run (§1). No shell or files of its own; web only with own web. The person's user settings load (where Claude Code may keep how it signs in), never a folder's; their MCP servers do not; Ensemble's hook steps aside (ENSEMBLE_RUN); the turn is not saved as a Claude Code session. - Codex:
codex app-server, a thread with Ensemble's instructions asbaseInstructions(thread/start, kept for the chat;thread/resumeon its next turn, with the instructions again; ephemeral for a one-off), everyturn/startwithenvironments: [](a resumed thread does not keep it), a read-only sandbox without network (with own web, Codex's ownweb_search: "live"),environments: [](no shell or file tools; a Codex that rejects the field starts without it), every approval declined (answerCodexTurn). - Grok Build, OpenCode, Gemini CLI, Antigravity, Mistral Vibe, Qwen Code, Kimi Code: started as for a run (§3.3) in the chat's folder, with Ensemble's instructions leading a session's first message (
session/new;session/load, or Antigravity'ssession/resume, after, when the agent offers it); a session that starts in a mode that acts without asking is set to the asking one first (Antigravity, Vibe), so nothing of the agent's own runs unasked; the tier's model and thinking level from the session's options; every permission refused except Ensemble's tools (answerAcpTurn). - Cursor's agent: ACP with Ensemble's instructions leading a session's first message (
session/loadafter); every permission refused except Ensemble's tools (answerCursorTurn); the turn's tokens fromsession/prompt'susage. - Copilot CLI:
copilot [-p <prompt>] --stream on --output-format json --allow-all-tools --deny-tool shell --deny-tool write [--deny-tool url --excluded-tools web_search, without own web] --disable-builtin-mcps --no-ask-user --no-auto-update --additional-mcp-config @<file> [--session-id <id>] [--attachment <file>…] [--model] [--reasoning-effort] [--context long_context]; the long window when a tier set to that Copilot model chose it (contexton the tier,tierContextinsubscriptions/turn.ts, sent to a paired computer on the turn's spec). From Copilot 1.0.78 (copilot --version, asked once per process) the prompt is piped on stdin, which no command line limits; before, it goes as-pand is cut to 28,000 characters on Windows (200,000 elsewhere), oldest text first, which could cut a node's instructions.subscriptions/copilot-output.tsreadsassistant.message_deltaandassistant.messagefrom that process, and the turn's tokens from--usage-output-file(Copilot 1.0.81 on;modelMetrics[model].usage, input counting cache reads and writes), which holds the whole session's totals: the computer keeps them with the chat's session (usageinassistant-sessions.json) and a resumed turn counts only what it added; per-callassistant.usageevents, from a Copilot that sends them, are the turn's own; missing counts stay null, and Copilot's billing units (cost,totalNanoAiu) are never treated as USD or credits. No quota endpoint or session history is read.
The agent's environment is cleaned as in §6.
Files on the message (subscriptions/turn-files.ts). Every file attached to the person's message is delivered to the agent's computer: it travels with the turn as files (TurnFile: id, name, media type, kind, size; TURN_FILES_MAX 8), passed to runSubscriptionTurn with a reader. The desktop runtime and a computer that reports assistantTurns: 2 get the files; an older computer gets a note after the message naming them and saying Ensemble on that computer must be updated to receive files (25 §4.9). runAgentTurn downloads each file into the turn folder's files/ (saveTurnFiles, names made safe with diskName, a repeat gets " (2)"), skipping a file over 32 MB and anything past 64 MB together. Each file then goes inside the agent's message in the agent's own input form. A file stays out of the message only where putting it in would fail the whole turn, because the agent's message format has no place for that kind; the message names it, says it is saved in files/ and why it is not inside the message. Ensemble does not read files for the agent and its extracted text is not added (files/extracted-text-policy.ts, off). A text file's own contents are the file and go as they are.
| Agent | Inside the message (its own input form) | Saved in files/ only, because the message format has no place for it |
|---|---|---|
| Claude Code | Pictures (PNG, JPEG, GIF, WebP, up to 3.75 MB) as image blocks; PDFs up to 32 MB as base64 document blocks; text files (text, Markdown, code, CSV, TSV, JSON, XML, YAML, SVG) as text document blocks, up to 2 MB whole (longer: the first 60,000 characters) |
Pictures over 3.75 MB, PDFs over 32 MB, Word (.docx, .doc), Excel (.xlsx, .xlsm, .xls), PowerPoint (.pptx, .ppt), OpenDocument (.odt, .ods, .odp), RTF, Keynote, Numbers, HEIC, TIFF, BMP, AVIF and other pictures, audio, video: Claude's message input takes pictures, PDFs and text only, and the chat turn gives Claude Code no file tools (--tools "") |
| Codex | Pictures (PNG, JPEG, GIF, WebP) as localImage items (the saved path); text files as their contents in a text item, up to 60,000 characters |
PDF (text and scanned), Word, Excel, PowerPoint, every other document, other picture formats, audio, video: Codex's turn/start input has no document type, and the chat turn declines every command (approvalPolicy untrusted, all approvals declined), so Codex cannot open the saved file |
| GitHub Copilot | --attachment for pictures (.png, .jpg, .jpeg, .gif, .webp) and documents (.pdf, .doc, .docx, .ppt, .pptx, .xls, .xlsx, .odt, .rtf); text files as their contents in the prompt (--attachment refuses .md, .csv, .txt, .json, .html and fails the whole run) |
.ods, .odp, .key, .numbers, HEIC, TIFF, SVG, audio, video: --attachment does not take them. Attached documents reach Copilot's model as Copilot handles them: with its default model (copilot 1.0.103) a .docx, .xlsx and .pptx arrived as a name only and a scanned PDF was not read |
| Cursor's agent, Grok Build, OpenCode, Gemini CLI, Antigravity (ACP) | Pictures as image blocks when initialize reports promptCapabilities.image; PDFs as resource blocks with the bytes when it reports embeddedContext; text files as resource blocks with their text when it reports embeddedContext (up to 2 MB), else as their contents in a text block |
Word, Excel, PowerPoint, OpenDocument, RTF and every other non-PDF document; pictures when the agent does not report image; PDFs when it does not report embeddedContext. These get a resource_link to the saved file and a note: Office types are never embedded (the models behind these agents refuse them inline) and the chat turn refuses the agent's own file permissions, so the link cannot be opened |
On a Copilot before 1.0.78 on Windows the files' contents in Copilot's prompt are cut first, so Ensemble's instructions stay within the 28,000-character command line (copilotPrompt); piped on stdin nothing is cut. Closing a gap is per agent, without Ensemble reading the file: for example a document-reading MCP server or the agent's own read tools limited to the turn's files/ folder (38 §7).
Tools. The agent starts Ensemble's MCP server (scripts/ensemble-ask-mcp.mjs with ENSEMBLE_ASSISTANT_TURN), which lists and calls the turn's tools through this computer's loopback API (GET /api/engine/assistant/tools, POST /api/engine/assistant/tool) with the turn's own secret (subscriptions/bridge.ts). Each call runs where the data is, through the same code as a model-key turn (runOne in assistant/agent.ts): same space, write policy and refusals, at most 24 calls per turn (MAX_COMPUTER_CALLS; an Ensemble Node's turn 60, as maxTurns is capped at 64 for older computers). A turn on the person's plan spends none of Ensemble's included credits: hub_web_search skips Foundry's search there (ownPlan on the tool context) and answers from the free search, and a node's web_task, which runs on a server model, is left out. Every agent waits up to 10 minutes for one call (TOOL_TIMEOUT_MS: Claude Code's MCP_TOOL_TIMEOUT, Codex's tool_timeout_sec, the timeout in Copilot's MCP config, whose own default is shorter), as a node's browser task or a slow connected app takes minutes; a relayed turn whose computer is waiting on a call is working, not quiet (quiet in subscriptions/relay.ts), so only the 20-minute limit ends it. When hub_how_to loads a playbook, its tools are added and the server sends notifications/tools/list_changed. In the desktop app the tools run in its own process; from Ensemble on the web the turn travels to the computer and back (25).
Problems are said in words: no computer paired, none online (with when last seen), Ensemble too old there, the agent missing (with the vendor's install link), not signed in (with where to press Sign in), the plan's limit reached (shown like a quota), or the agent's own message.
Tests: subscriptions/subscriptions.test.ts (including each agent's form for files), subscriptions.integration.test.ts (the file route), agents.e2e.test.ts, remote/assistant.test.ts.
9. The worker's report and evidence
A coding agent reports its own result, separate from its closing words, and attaches what its owner should see (evidence/run-tools.ts). Ensemble Node reads the report to decide whether a step is done (35 §8.12).
The tools. A task run's ensemble MCP server (scripts/ensemble-ask-mcp.mjs) has report_result, attach_evidence, ensemble_context, ensemble_search, ensemble_actions, ensemble_act, ensemble_apps, ensemble_app_call and secret_http next to ask_person, and the browser and capture tools of the computer (below); a Chat run has the board tools, ensemble_context and the connected apps instead. Its initialize answer carries instructions (instructionsFor): the agent acts in Ensemble through this server and its brief, and never opens Ensemble's web app to sign in or to read the person's work (39 §4). Every engine loads that server, so every engine has them. They reach POST /api/engine/tool on loopback with the run's secret, and the judge passes them without a prompt (ENSEMBLE_OWN_TOOLS in engines/judge.ts; Copilot's --allow-tool 'ensemble(<tool>)'), except ensemble_act and ensemble_app_call, which change things and are decided by the run's mode like a command.
| Tool | Input | What happens |
|---|---|---|
report_result |
A StepReport (node/report.ts): outcome (done, pass, fail or blocked), summary, and optionally checks (each with its verdict), subject (the commit or content hash it produced), evidence and blockedBy |
Checked: its shape, and that the candidate exists (subject.commit is in the folder's repository; subject.contentHash is the last commit's tree or the working tree's, worked out with a throwaway index). Then redacted and kept on the job (workspace_jobs.report, a report event). A refusal is the tool's answer, so the agent can fix it and call again. A later call replaces the report. |
attach_evidence |
{path, kind, caption?, requirementId?}; kind is screenshot, image, video, trace, report, log or file |
A file outside the run's folder is judged as a read by the run's rules first. The file is checked and copied into the run's evidence folder (below), with an evidence event. The answer gives its id, for checks[].evidence. |
ensemble_context |
{item?, rel?, offset?, limit?} |
The person's records around the task (workspace/context-tools.ts, 03 §5): without item the task's context packet (its meeting and the words asked, project, people and roles, dependencies, related open work, done-before, files); with an ensemble://<kind>/<id> handle from a result, that record and its links; with rel, one relation in pages. A task claimed from Ensemble on the web is answered there, for the job's space, under the job's lease (POST /api/devices/self/jobs/:id/context); otherwise this computer's Ensemble answers. |
ensemble_search |
{query, kinds?, limit?} |
Task runs only (Chat keeps its board search). Ranked word search across the person's records, closest to the task first, each hit with a handle for ensemble_context. Answered where ensemble_context is. |
ensemble_actions, ensemble_act |
{groups?, names?}; {action, input} |
Task runs only. Ensemble's everyday changes around the task, the part of an editor's key's allowlist not marked keyOnly (CONTEXT_BRIDGE §5.1): update the task's fields, add or edit pages, put a task or page on a board, make a diagram or plot, link people and projects, add a meeting note (runRunAction in workspace/context-tools.ts). A task claimed from Ensemble on the web is answered there under the job's lease (POST /api/devices/self/jobs/:id/act), and only in the computer owner's own space; a local task on this computer's Ensemble. Changes run at once and are in Ensemble's Undo |
ensemble_apps, ensemble_app_call |
{app?, tools?}; {tool, input} (the Context Bridge's own shapes) |
Also in Chat runs. The apps the person connected to Ensemble (Notion, GitHub, Gmail, Slack, Linear, Hugging Face…, 31) and their tools, as an editor's key with apps allowed has them (CONTEXT_BRIDGE §5.2): listed (each tool's first sentence; with app or tools its full description and inputSchema) and called with the person's own connection, so the agent needs no app keys. The suite connected, the product on and the scopes granted are checked as for the assistant; a change in an app runs at once, is ledgered and cannot be undone from Ensemble, and is refused while Settings › Assistant has connected-app changes off. Answered where ensemble_actions is; in a space shared with the computer's owner, refused (the space owner's apps are not the run's) |
secret_http |
{ref, url, method?, headers?, body?} |
Also in Chat runs. A request this computer makes with a saved secret put in where {{NAME}} appears in a header or the body, only to that secret's allowed origins and only when the run may use it; the answer has the value hidden (35 §8.7, credentials/secret-http.ts). A run whose internet is off is refused. |
Secrets by reference. A run that may use a secret with allowed destinations is told its name and where it may go, never its value (secretRules in lib/run-secrets.ts), and, when the run names the secret, gets git settings that make git over https to those hosts sign in with it (credentials/git-credential.ts). The secret is never in the run's environment.
Browser and capture. The server also lists browser_* and capture_* (25 §10), as GET /api/engine/tools gives them for a task run: all of them when the computer has a browser for Ensemble, else only capture_status, which says what ensemble capture setup adds. A call runs for the run's own operation (its job), writing into its evidence folder (evidence/capture-tools.ts). They follow the run's mode like any MCP tool, for every engine: Review only and Run unattended use them freely, Ask asks before them (Allow for this run on one browser tool covers the run's other browser tools, and the same for capture), a read-only run is refused. What it writes is the run's evidence at once, with no attach_evidence: the answer names each file's id (to put in checks[].evidence after capture_check), or says it is held for the owner's preview (a saved secret or key on screen, a desktop app's window, a site the owner previews first); a held file goes nowhere until the owner releases it (35 §8.16). A run with the internet off opens and loads only this computer's own addresses (loopbackOnly). capture_app_start starts an app in the run's folder (or one it was let into), sandboxed as the run is. A run may read its own evidence folder, to look at its screenshots. When the run ends, a recording still going is finished and kept, and its tabs, apps and displays end (finishRunCapture).
Instructions. Every engine's brief has one line on this (reportingNote in workspace/agent.ts, part of workingNotes): at the end call report_result, after attach_evidence for files the owner should see (with the path of the run's evidence folder), and without MCP tools write the report as JSON to .ensemble/result.json. A commit or contentHash at the top of a report is taken as its subject.
Without MCP. When the run ends with no report from the tool, the runtime reads .ensemble/result.json in the run's folder (collectRunResult). It reads only a regular file with one name, at most 256 KB, written during this run, without following links. It must be a valid StepReport with a candidate that exists. The files it names under evidence (kinds screenshot, video, report, log, file or evidence; not URLs) are attached under the same checks, and so is any file left in the run's evidence folder. A problem is recorded as a report_refused or evidence_refused event; it never fails the run. Chat runs and interrupted runs are not read.
The evidence folder (evidence/files.ts) is <Ensemble's data folder>/evidence/<job id>, never in the repository. Agents cannot write there (the sandbox denies Ensemble's data folder), so the runtime copies each file in. A file is read once, by descriptor:
- opened without following links (
O_NOFOLLOW); a regular file with one name only (a hard link is refused); its real path is checked again after opening, so a file swapped after the check is refused; - never a credential file:
.env*, key and certificate files (*.pem,*.key,id_rsa*,id_ed25519*and the like), credential stores (.npmrc,.netrc,.git-credentials…), shell histories, or anything under.ssh,.aws,.gnupgor.git(credentialPathReasoninevidence/limits.ts); - nothing in Ensemble's data folder or the browser profile folder, and nothing the outside floor refuses to read (
outsideRefusal: keys, sign-ins and browser data in the home folder;evidenceFloor); - never a file that holds a saved run-secret value;
- at most 100 MB per file, of any kind, and 100 files per run;
- the kind must match the type read from its bytes (a
screenshotmust be an image).
What is copied is exactly what was checked, and the copy is checked again before it is sent. Folders of runs that ended 30 days ago are removed. A paired computer uploads each file to the web unless ensemble evidence off (25 §4.11).
Tests: src/evidence/report.integration.test.ts (the MCP server end to end, content hashes, the result file), src/evidence/files.test.ts (the file rules), src/evidence/capture-run.integration.test.ts (the browser and capture tools through the MCP server: listed for task runs only, captures kept and held, a blank screenshot failing its check, internet off, a recording finished at the run's end, held files to newer and older servers) and src/evidence/captures.test.ts (recording captures as evidence).
10. Support matrix
What each agent does for a run Ensemble starts, including the runs a node starts (35 §8), and what each model provider does as a node's brain. Checked against Claude Code 2.1.304, Codex 0.163.0 and GitHub Copilot CLI 1.0.108 on Linux; Copilot's skill runs, tool permissions, allow_all and MCP tools were checked live, the others through stand-ins speaking each protocol (engines/*.e2e.test.ts). Mistral Vibe 3.1.1, Qwen Code 0.27.1 and Kimi Code 2.5.3 were started live signed out (version, ACP initialize, the sign-in check); signed-in runs follow their documented protocols, as do Cursor's agent, Gemini CLI, OpenCode, Grok Build and Antigravity.
The desktop chat apps. The Claude app, the ChatGPT app and the Cursor app are not driven themselves: each ships (or sits beside) the agent Ensemble drives, on the same subscription and sign-in. The Claude app's Code tab is Claude Code (Ensemble finds the copy inside the app when there is no claude command); the ChatGPT app's Codex is the Codex CLI (~/.codex, the app's own copy when there is no codex); the Cursor app's agent is cursor-agent, signed in with agent login. A task can still be opened in the app itself (§4), where the app's own settings apply.
10.1 Coding agents
| Claude Code | Codex | Cursor's agent | GitHub Copilot | Grok Build, OpenCode, Gemini CLI, Antigravity, Mistral Vibe, Qwen Code, Kimi Code | |
|---|---|---|---|---|---|
| Driven as | claude -p, stream-json, a PreToolUse hook on every tool (§1) |
codex app-server, JSON-RPC (§2) |
cursor-agent acp (§3) |
copilot --acp (§3) |
ACP, one runner (§3.3) |
Permissions reach the judge and agent_decisions |
Every call, through the hook | Every approval Codex asks for (commands with their folder, file changes, more access, MCP forms); with its sandbox in Review only or Run unattended, Codex asks only to leave the sandbox, which is the floor | Every session/request_permission; a command in the person's own Cursor allowlist runs without asking |
Every session/request_permission; allow_all and autopilot are set off first; a tool the person allowed "always" for that folder runs without asking |
Every session/request_permission, with each agent's own prompts forced on (Grok --permission-mode default, OpenCode OPENCODE_PERMISSION, Gemini and Qwen --approval-mode default, Antigravity's and Kimi's default mode, Vibe's ask agent) |
| Questions | ask_person, AskUserQuestion |
ask_person, item/tool/requestUserInput, MCP forms |
ask_person, cursor/ask_question, forms |
ask_person (--no-ask-user), forms |
ask_person, forms (elicitation/create: OpenCode's question tool) |
ask_person, report_result, attach_evidence, secret_http |
--mcp-config, day-long timeout |
config.mcp_servers, day-long timeout |
session/new mcpServers |
--additional-mcp-config file, allowed by ensemble(<tool>), day-long timeout |
session/new mcpServers; the wait is the agent's own MCP timeout |
| Browser and capture tools (§9) | Yes, judged by the mode | Yes | Yes | Yes, asked about and judged | Yes |
| Models | The plan's list from claude itself, else CLAUDE_MODELS |
model/list |
cursor-agent models (names) |
models.list (cost, levels, two windows) |
The session's model option (opencode models, grok models, Antigravity's fixed list; Vibe, Qwen and Kimi from an ACP probe) |
| Thinking level | --effort, per model (claudeLaunch) |
effort on turn/start |
Not set: Cursor names levels in its model ids | --reasoning-effort |
The session's thought-level option |
| Long context, fast mode | [1m]; fastMode |
One window; the fast service tier | — | --context long_context |
— |
| Resume and steer | --resume; a steer is the next run resuming |
thread/resume |
session/load |
session/load |
session/load (Antigravity session/resume) |
| The person's skills | ~/.claude/skills, ~/.agents/skills, enabled plugins; a project's .claude/skills in a folder trusted by name |
~/.codex/skills (with .system), ~/.agents/skills, plugins; a project's in a trusted folder |
Its own and ~/.cursor/skills |
~/.copilot/skills, ~/.agents/skills, installed plugins, built-in skills |
Gemini ~/.gemini/skills and extensions; OpenCode ~/.config/opencode/skill(s); Vibe ~/.vibe/skills; Qwen ~/.qwen/skills and extensions; Kimi ~/.kimi-code/skills; all of these and ~/.agents/skills; Antigravity runs in Ensemble's own home, so the person's Antigravity skills do not load |
| Sign-in check | claude auth status --json |
account/read |
cursor-agent status |
auth.getStatus (keychain-aware) |
grok models, opencode auth list, an ACP probe (Gemini, Vibe, Qwen, Kimi: a session that fails for want of a sign-in, or Vibe's "Missing API key", is signed out), the sign-in file (Antigravity) |
| OS sandbox | Its own (Seatbelt, bubblewrap) | Its own | Its own | --sandbox |
Gemini and Qwen on macOS only; none for the others: a sandboxed run stops at once, and a node's start says so before the run (refuseUnsandboxable in node/sessions.ts) and picks an agent that has one when its profile names none |
| Version gates | Models by minVersion |
— | — | — | Gemini's and Qwen's --acp or --experimental-acp, read from its help |
Skill folders (agentSkillRoots in engines/skills.ts): ~/.agents/skills; ~/.claude/skills, ~/.claude/plugins/marketplaces, ~/.claude/plugins/cache (or under CLAUDE_CONFIG_DIR); ~/.codex/skills, ~/.codex/plugins/cache (or under CODEX_HOME); ~/.copilot/skills, ~/.copilot/installed-plugins (or under COPILOT_HOME) and Copilot's own package (~/.cache/copilot/pkg); ~/.cursor/skills, ~/.cursor/skills-cursor; ~/.gemini/skills, ~/.gemini/extensions, ~/.gemini/antigravity/skills; ~/.config/opencode/skill and skills; ~/.vibe/skills (or under VIBE_HOME); ~/.qwen/skills, ~/.qwen/extensions; ~/.kimi-code/skills (or under KIMI_CODE_HOME). A link into one of them is followed. Credential files there (.env, keys) and git's own files are left to the usual rules.
Adding an agent. Each further ACP agent needs an entry in ACP_AGENTS (its program, the mode that asks, its sign-in), its id in SUBSCRIPTION_PROVIDERS, CODE_ENGINES and ACP_ENGINES (and ModelProvider), a detection (probeAcpAgent: version and acpProbe), a sign-in command (LOGIN_ARGS), session deletion (deleteNativeSession), its skill folders, its words in engineNamed, shared trust when it keeps a trust file, its MCP setup (ensemble mcp setup, the Connect page) and its label and mark on the web and in the CLI. Mistral Vibe, Qwen Code and Kimi Code were added this way. Goose (goose acp), Trae (traecli acp serve) and Kiro (kiro-cli acp) are further such agents. Pi speaks its own JSON-lines protocol, not ACP, and needs a driver.
10.2 A node's brain
| Brain | How a node turn runs | Its tools | Thinking |
|---|---|---|---|
| Included models (GPT-6 Luna, GPT-6.1 Sol and the server's others) | Ensemble's tool loop on the server (11) | All of the turn's | The node's level (low unless set) |
| API keys: OpenAI, Anthropic, Google, Mistral, Kimi, Qwen, OpenRouter, GitHub Copilot | The same loop, on the provider's API (Responses or chat completions, Anthropic messages) | All, when the model takes tools | The node's level, as the provider takes it |
| xAI, DeepSeek, Llama and other vendors | Through OpenRouter (or Grok Build as an AI plan); there is no xAI or Azure key of its own | As above | As above |
| Local models (Ollama, LM Studio or any OpenAI-compatible server) | Provider ollama, or the OpenAI key with its own base URL, where Ensemble's server reaches it: the desktop app and a server of one's own; on the hosted site only for its operator. From the hosted site, a model on the owner's computer runs as an AI plan whose agent is set up with it (OpenCode's Ollama or LM Studio provider, Codex's own providers, Copilot's own provider) |
All, when the model takes tools | As above |
AI plans on a computer (each of SUBSCRIPTION_PROVIDERS) |
The plan's agent on the paired computer runs the turn with Ensemble's tools and its own web search and page reading (§8), through the relay. One session per node conversation, resumed from message to message with only the change in its instructions, in the node's project folder; a session unused 7 days is deleted (NODE_SESSION_KEEP_DAYS) |
All of the turn's except web_task (the agent drives the browser itself), at most 60 calls, each waited for up to 10 minutes |
The node's level when its owner set one (turnEffort in node/brain.ts), else the agent's own default |
A node's model is checked against everything its owner can use (modelProblem in node/model.ts), not only the first 40 per provider the picker lists.
Tests: engines/judge.test.ts (skill folders and scripts, run rules by kind), engines/acp.test.ts (forms), engines/codex.test.ts (a command's folder, MCP forms), engines/cursor.test.ts, engines/copilot.e2e.test.ts (allow_all set off, Ensemble's own tools allowed by name), remote/policy.test.ts and devices/routes.integration.test.ts (for this run from the web), node/session-control.test.ts (an agent with a sandbox), node/model.test.ts, subscriptions/subscriptions.test.ts and agents.e2e.test.ts (tool waits).