Source: https://aircodezero.com/docs/remote-sessions

# Remote sessions

Save the URL and a scoped token from another personal AirCode Ø instance, then
choose that connection in **New session → Run on**. The remote instance invokes
its own connected Claude Code, Codex or another available native provider. Your
current client displays the conversation and terminal as the remote agent works.

Choose where the project lives. With **Project location → This instance**, A
owns project P1 and B runs the agent using B's provider account. B requests the
files it needs through workspace tools; A reads, edits and runs commands in its
actual P1 directory. There is no clone or final synchronization. Requested code
and command output reach B and its AI provider. Provider credentials and usage
accounting stay on B. Dependencies, builds and tests stay on A.

A opens an authenticated WSS connection to B, which carries tool calls in both
directions. The machines can be in different networks or data centres. A does
not need an inbound port or public address; B needs a reachable HTTPS instance
URL, and its proxy must support WebSocket upgrades. Both instances must support
connected workspaces. Closing the browser or mobile view leaves this server
connection in place. A must remain running for the agent to use its project.

**Project location → Remote instance** preserves the other mode: both the agent
and the token's authorized project are on B. These two modes are explicit;
selecting a similarly named project on B never means P1 on A.

## Connect your two instances

1. On the remote instance, connect your native provider account in **AI Usage**.
2. Open **Settings → AI gateway**, or **More → AI gateway** on mobile, and enable
   the gateway. Under **Application tokens**, enable **Allow connected workspaces**,
   name the token, and select **Create token**. Optionally enable **Allow remote
   sessions** and choose an **Authorized project** for working on B's files too.
3. Copy the token from its one-time reveal and the displayed **Base URL**.
4. On your current instance, open **Settings → AI gateway → Remote instances**.
   Enter a connection name, the remote instance URL or Base URL, and the token;
   select **Save connection**. You can also add it from
   **New session → Run on → Add remote instance…**.
5. In **New session**, choose the saved connection under **Run on**. Check the
   **Project location** defaults to **This instance** when the token permits it.
   Keep or choose P1 under **Project on this instance**, choose **Harness → Model
   → Effort → Permissions**, enter your prompt, and select **Start session**.
   The current workspace adapters support Claude Code and Codex; available
   choices come from B's live catalogue. Other engines retain the remote-project mode.

The token is sent once to your current server for encrypted storage. Web and
native clients subsequently use a connection identifier. The website never
receives the URL or token. Use HTTPS for Internet connections or your trusted
private network. Gateway tokens are personal credentials, not share links.

The workspace bridge and its MCP tools start only for **Project location →
This instance** with a remote agent. Ordinary sessions start none of these
services. Closing the last distributed session stops the private bridge
listener; closing only its browser or mobile view leaves the session running.

Codex currently requires native file-backed sign-in (`auth.json`) on B for this
mode; keyring-only sign-in is refused explicitly. Its private configuration
uses links to the native account and conversation files, without copying OAuth
values. CLI compatibility and machine-managed policies still apply.

## Work with the remote agent

### Prompt to app and Ask anything

Open either door on A, open **Model** (the agent settings chip in the composer),
then select your saved B connection under **Run on**. Choose **Harness → Model
→ Effort → Permissions** from B's catalogue. You can also save a connection
there with **Add remote instance…**. Both instances need a version advertising
`conversationWorkspace: 1`; missing scope or unavailable B blocks launch.

For **Prompt to app**, describe the application and confirm its app/project
names as usual. The project, saved Application, development supervisor and
preview belong to A. Follow-up messages, visual edits and selected screenshot
regions go to the B agent. **Plan first → Single agent** can submit its Plan
checkpoint on A; Convergence currently requires **This instance**.

For **Ask anything**, enter your question and any files. Its private conversation
folder and the **Files** inventory belong to A. Attached images and PDFs can be
read through the workspace tools. **Convert to project** copies that A folder
into an ordinary A project.

These doors keep ordinary A conversation identities and history. Opening an
ended conversation and selecting **Resume** explicitly resumes the same native
context on B; reconnecting the network only reconnects readers. Attachments,
command output and the private incremental transcript remain available on A.
The agent settings can change A's workspace permissions and B's model/effort.
The terminal displays B and is read-only; commands run on A and appear in Chat.
Browser, planning and Knowledge tools are exposed on demand through the
conversation's existing A capabilities. No helper or bridge is added to an
ordinary local conversation by this feature.

**Chat** shows new transcript events and current permission/question dialogs.
Send follow-up messages, answer the dialog, or use **Stop** to interrupt the
current response. In connected-workspace mode, permissions are enforced by A:
read-only modes refuse writes and commands; normal modes ask in this conversation;
edit-approval modes permit file edits but ask for shell commands; Bypass permits
the requested operations. The separate New session remote-workspace form fixes
permission mode at launch; the two conversation doors above expose A's normal
permission control.
The **Agent terminal (read-only)** shows B's actual provider output; project
commands run on A through workspace tools and stream their output in Chat.
In remote-project mode, **Terminal** also accepts native input and resize.

The terminal transports live output over WebSocket. Chat uses a continuous SSE
response and publishes events as the provider writes its transcript; some
providers journal a completed message rather than every token. This does not
promise token-level Chat updates for every CLI. The separate text-only
`/chat/completions` API still uses **buffered** SSE.

A network reconnection reads from the last transcript cursor. It never starts
another agent or resends a message automatically. After an uncertain launch,
reopen **New session**, select the connection and look under **Remote
conversations**. An explicit retry of the same request keeps its intention id.
Do not change the id just to bypass an uncertain result.

Closing the view detaches only its reader. Removing the saved connection closes
its workspace access and stops its owned commands on A; it retains B's conversation.
**Close session** stops the agent and its owned commands; its
transcript remains on the remote instance and can be reopened for reading.
Start a new session to run an agent again after closing it. Saved remote
conversations are listed under their connection in New session.
This paragraph describes the separate New session remote form. Prompt to app
and Ask anything keep their regular history and explicit Resume action on A.

## Scope, revocation and limits

An ordinary application token grants text generation only. **Allow remote
sessions** adds access to one project and only the conversations created with
that token. It does not grant owner login, settings, other conversations,
arbitrary owner file APIs or token management. The agent's tools use the
permissions selected at launch; choosing a project is not an OS sandbox.

**Allow connected workspaces** is a separate opt-in and never appears on old
tokens automatically. Each launch on A binds one project directory identity,
connection, permission mode and intention. B has no API for changing that root.
File paths are relative, external symlinks are refused, and writes require the
SHA-256 observed when reading the file. Stale or ambiguous edits are refused.
Approved shell commands have A's user permissions; a project cwd is not an OS sandbox.

Workspace effects have durable receipts. Reconnecting resends only the same
operation identity and returns its recorded result. A server crash during an
effect reports uncertainty instead of rerunning it. Owned commands stop when
A's server exits, when access is revoked, or when the session closes. Inspect
an uncertain command's effects before requesting a new one. Restarting either
instance never launches another provider or replays a prompt.

Tokens expire after 90 days. Revoking the token or disabling the remote gateway
removes its session authority and stops its agents. Expiry is checked on every
operation and by a bounded background check, including after a server restart.
Removing a saved connection does not revoke the original token: revoke it on
the issuing instance if you want to withdraw authority.

There are at most 20 active gateway sessions per issuing instance and four live
readers per token, with 20 readers across the gateway. Each opened Chat uses one
reader; opening Terminal can add another. These are local resource limits, not
subscription allowances. Provider quotas and compatibility requirements still
apply; no provider CLI is installed or updated by connecting.

Connected workspaces add at most 20 persistent tool channels on B and four per
token, with four in-flight calls and four running project commands per workspace.
A allows 20 active workspaces and 20 commands overall. Frames are limited to
2 MiB, regular files/results of edits to 8 MiB, calls to 20,000 per workspace, and commands to
ten minutes. Tool responses page file contents and explicitly mark truncated
command tails. Read pages are at most 128 KiB; write/patch text arguments have
their own schema limits and must fit in one frame. These are local resource
limits; no extra public origin is created.

## Session API for a personal backend or AI tool

The [session OpenAPI schema](/docs/remote-sessions/openapi.json) describes this
protocol. Use `Authorization: Bearer TOKEN` on every call to the issuing
instance's `/api/ai-gateway/v1` Base URL. Refuse redirects; never use URL/query
credentials. Do not put the token in prompts, source code or browser storage.

| Endpoint | Meaning |
| --- | --- |
| `GET /session-catalog` | Protocol version, optional authorized project, `workspaceAccess`, and live engine/model/effort/permission catalogue |
| `POST /sessions` | Start a native agent; required UUID `intention`, `engine`, `model`, `permissionMode`, `initialPrompt`; optional `effort`, `name` |
| `GET /sessions` | Up to 100 recent conversations created by this token |
| `GET /sessions/{id}` | Current summary of a conversation owned by this token |
| `POST /sessions/{id}/resume` | Explicit continuation of a conversation workspace with UUID `intention`, `initialPrompt`, optional `model`/`effort`; same native B context |
| `POST /sessions/{id}/events` | Continuous SSE; JSON `{ "offset": null }` initially, then the last returned offset on reconnect |
| `POST /sessions/{id}/transcript` | Read a named page; send `offset` for newer events or `before` for earlier messages |
| `POST /sessions/{id}/input` | Follow-up: UUID `intention` and non-empty `text` |
| `POST /sessions/{id}/interaction` | UUID `intention`, current dialog `id`, and the supported option/text/dismiss answer |
| `POST /sessions/{id}/command` | UUID `intention` and `action`: `interrupt`, `close`, `model`, `effort` or `permission`; selection changes require `arg` from the catalogue |
| `GET /sessions/{id}/terminal` | Authenticated WebSocket upgrade; raw terminal output, JSON input `{ "t": "i", "d": "text" }`, resize `{ "t": "r", "c": 100, "r": 30 }` |
| `GET /workspaces/{id}` | Recover B's session by the original workspace UUID; never create a replacement on a lost response |
| `GET /workspaces/{id}/channel` | A opens an authenticated WebSocket to B before launch; B sends workspace tool calls, A returns their results |

For the built-in A-workspace bridge, use A's owner-authenticated API from the
current client: save B with `POST /api/ai-gateway/connections`, discover choices
with `GET /api/engines?remoteConnectionId=ID&workspaceLocation=local`, then call
`POST /api/ai-gateway/connections/ID/sessions` with `localProject: "P1"` and the
catalogue selection. A supplies the workspace identity and reverse channel.
The returned `agremote_…` name opens A's `/api/ai-gateway/remote-sessions/NAME/events`
and `/interaction`, including approvals on A. B's gateway token cannot authorize
these owner endpoints. The next example shows this complete distinction.

For **Prompt to app** and **Ask anything**, use the normal owner-authenticated
`POST /api/sessions` on A with `remoteConnectionId: "SAVED_CONNECTION_ID"` and
B's catalogue selection. Use `askAnything: true` for a question, or `project`
and `createMode: true` for an existing application project. Creating a new
application first uses `POST /api/create-mode/projects/confirmed` with `prompt`,
`appName` and `projectName`. Retain `idempotencyKey` across an uncertain launch.
The response has A's ordinary `tmuxName`, `sessionId` and `remoteAgent` metadata.
Follow-up, transcript, workspace and resume use A's normal conversation APIs;
never substitute the gateway token for A's owner credential.

If implementing a separate connecting server, consult the
[workspace protocol and tool schemas](/docs/remote-sessions/workspace-protocol.json).
Launch on B with `workspace: { id: UUID, project: "P1" }` only after implementing
the channel, pinned local root, permissions and durable operation receipts.
A label alone does not provide file access. B's terminal input and native
permission changes are disabled for these sessions.

An SSE `update` contains `{ id, project, session, transcript }`.
`transcript.events` is the new event page, `offset` is its opaque integer cursor,
and `interaction` or `terminalAttention` describes current input needs. Keep
complete SSE frames across network chunks. Ignore comment keepalives. On an
`error` event, retain the conversation and reconnect for reading. Never infer
that an operation did not happen from a lost network response.

Permission modes, model ids and efforts come from `/session-catalog`, not the
text API's composite model ids. Session creation and actions reject unsupported
fields. Initial prompts and follow-ups are bounded to 64 KiB. Launch intentions
are scoped to the token; action intentions are scoped to a session. Reusing an
intention with different content is rejected. Once runtime delivery was
attempted, an unknown result remains `AI_GATEWAY_SESSION_UNCERTAIN` and cannot
be replayed automatically.

For a quota wall, wait for the provider's actual recovery; an unknown reset time
stays unknown. For `AI_GATEWAY_SESSION_ATTENTION`, answer the current dialog.
For revoked/expired access, create a fresh scoped token on the issuing instance.
For missing models, refresh the catalogue. Existing owner conversations cannot
be adopted with a new token.

## Prompt to app and Ask anything on A

```javascript
// Node.js 22+. Private backend only; never send these credentials to the website.
const origin = new URL(process.env.AIRCODE_LOCAL_ORIGIN);
const ownerToken = process.env.AIRCODE_LOCAL_AUTH_TOKEN;
const connectionId = process.env.AIRCODE_REMOTE_CONNECTION_ID;
const idempotencyKey = process.env.AIRCODE_REMOTE_INTENTION;
if (!['https:', 'http:'].includes(origin.protocol) || origin.username || origin.password
    || origin.pathname !== '/' || origin.search || origin.hash
    || !ownerToken || !connectionId || !idempotencyKey) throw new Error('Configure A, its saved B connection and one retained intention UUID');
async function call(path, body) {
  const response = await fetch(origin.origin + '/api' + path, {
    method: body === undefined ? 'GET' : 'POST', redirect: 'error',
    headers: { authorization: 'Bearer ' + ownerToken, 'content-type': 'application/json' },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  });
  if (!response.ok) throw new Error('A refused the operation: HTTP ' + response.status);
  return response.json();
}
const catalog = await call('/engines?remoteConnectionId=' + encodeURIComponent(connectionId) + '&workspaceLocation=local');
const engine = process.env.AIRCODE_REMOTE_ENGINE, model = process.env.AIRCODE_REMOTE_MODEL;
const permissionMode = process.env.AIRCODE_REMOTE_PERMISSION;
if (!engine || model === undefined || !permissionMode) {
  console.log(JSON.stringify(catalog, null, 2));
  throw new Error('Choose harness, model and permissions from B’s catalogue');
}
const selection = { remoteConnectionId: connectionId, engine, model, permissionMode,
  ...(process.env.AIRCODE_REMOTE_EFFORT ? { effort: process.env.AIRCODE_REMOTE_EFFORT } : {}) };
// Set AIRCODE_APP_PROJECT only for an existing A application project.
// To create one first, POST /create-mode/projects/confirmed with confirmed
// { projectName, appName, prompt }, then retain its returned name.
const project = process.env.AIRCODE_APP_PROJECT;
const session = await call('/sessions', { ...selection, idempotencyKey,
  ...(project ? { project, createMode: true } : { askAnything: true }),
  initialPrompt: process.env.AIRCODE_PROMPT || 'Explain how this distributed conversation works.',
});
console.log('Conversation on A:', session.tmuxName, 'Agent:', session.remoteAgent.instanceName);
// Read-only observation; never replay the launch or prompt to reconnect.
const transcript = await call('/sessions/' + encodeURIComponent(session.tmuxName) + '/transcript');
for (const event of transcript.events) if (event.text) console.log(event.text);
// Poll the same route with ?offset=LAST_OFFSET for incremental updates.
// Send an explicit follow-up to POST /sessions/NAME/input with { text }.
// Resume explicitly with POST /sessions { project: session.project,
//   resumeSessionId: session.sessionId, initialPrompt: 'Continue' }.

```

## Project on A, agent on B

```javascript
import { randomUUID } from 'node:crypto';

// A owns P1. B supplies the agent/account. Never send these values to the website.
// Save B once in A's Settings → AI gateway → Remote instances.
const origin = process.env.AIRCODE_LOCAL_ORIGIN;
const ownerToken = process.env.AIRCODE_LOCAL_AUTH_TOKEN;
const connectionId = process.env.AIRCODE_REMOTE_CONNECTION_ID;
if (!origin || !ownerToken || !connectionId) throw new Error('Configure A and its saved connection to B');
const url = new URL(origin);
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password
    || url.search || url.hash || url.pathname !== '/') throw new Error('Use A’s instance origin');
async function call(path, body, signal) {
  const response = await fetch(url.origin + path, {
    method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal,
    headers: { authorization: 'Bearer ' + ownerToken, 'content-type': 'application/json' },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  });
  if (!response.ok) throw new Error('A refused the operation: HTTP ' + response.status);
  return response;
}
const id = encodeURIComponent(connectionId);
const catalog = await (await call('/api/engines?remoteConnectionId=' + id + '&workspaceLocation=local')).json();
const engine = process.env.AIRCODE_REMOTE_ENGINE;
const model = process.env.AIRCODE_REMOTE_MODEL;
const permissionMode = process.env.AIRCODE_REMOTE_PERMISSION;
if (!engine || model === undefined || !permissionMode) {
  console.log(JSON.stringify(catalog, null, 2));
  throw new Error('Choose harness, model and permissions from B’s catalogue');
}
// Persist the intention before launch and reuse it if its response is lost.
const intention = process.env.AIRCODE_REMOTE_INTENTION;
if (!intention) throw new Error('Set AIRCODE_REMOTE_INTENTION to a new UUID once, then retain it');
const session = await (await call('/api/ai-gateway/connections/' + id + '/sessions', {
  intention, localProject: process.env.AIRCODE_LOCAL_PROJECT || 'P1',
  engine, model, permissionMode, effort: process.env.AIRCODE_REMOTE_EFFORT || '',
  initialPrompt: 'Read the project instructions, then explain which machine owns its files.',
})).json();
console.log('Conversation on A:', session.tmuxName);
const path = '/api/ai-gateway/remote-sessions/' + encodeURIComponent(session.tmuxName);
// Approvals from A appear as transcript.interaction with id "workspace:…".
// Only after the owner chooses an answer, send it to A:
async function answerCurrentInteraction(interactionId, option) {
  return call(path + '/interaction', { intention: randomUUID(), id: interactionId, option });
}
// This bounded reader displays complete events and pending owner decisions.
// It never approves an operation automatically.
const signal = AbortSignal.timeout(30_000);
const stream = await call(path + '/events', { offset: null }, signal);
const decoder = new TextDecoder(); let buffer = '';
try {
  for await (const chunk of stream.body) {
    buffer += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n');
    let boundary;
    while ((boundary = buffer.indexOf('\n\n')) >= 0) {
      const frame = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2);
      const data = frame.split('\n').filter(line => line.startsWith('data:'))
        .map(line => line.slice(5).trimStart()).join('\n');
      if (!data) continue;
      const update = JSON.parse(data);
      console.log(JSON.stringify(update));
      // Display update.transcript.interaction and let the owner choose an answer.
    }
  }
} catch (error) { if (!signal.aborted) throw error; }
// Disconnecting the reader leaves the agent and workspace connection running.
// An explicit POST path + '/command' with {intention:UUID,action:'close'} stops them.

```

## Live session on B’s project

```javascript
import { randomUUID } from 'node:crypto';

// Node.js 22+. Configure these privately on your application's server.
const base = process.env.AIRCODE_REMOTE_BASE_URL;
const token = process.env.AIRCODE_REMOTE_TOKEN;
if (!base || !token) throw new Error('Configure the remote Base URL and token');
const url = new URL(base);
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password
    || url.search || url.hash || url.pathname !== '/api/ai-gateway/v1') {
  throw new Error('Use the instance Base URL without a trailing slash');
}
async function call(path, body, signal) {
  const response = await fetch(base + path, {
    method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal,
    headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  });
  if (!response.ok) throw new Error('Remote request refused: HTTP ' + response.status);
  return response;
}
const catalog = await (await call('/session-catalog')).json();
const engine = process.env.AIRCODE_REMOTE_ENGINE;
const model = process.env.AIRCODE_REMOTE_MODEL;
const permissionMode = process.env.AIRCODE_REMOTE_PERMISSION;
if (!engine || model === undefined || !permissionMode) {
  console.log(JSON.stringify(catalog, null, 2));
  throw new Error('Choose harness, model and permissions from this catalogue');
}
// Keep this intention for an explicit retry of this exact launch request.
const intention = process.env.AIRCODE_REMOTE_INTENTION || randomUUID();
const session = await (await call('/sessions', {
  intention, engine, model, permissionMode,
  effort: process.env.AIRCODE_REMOTE_EFFORT || '',
  initialPrompt: 'Describe the project without changing its files.',
})).json();
console.log('Remote conversation:', session.id);
// Demonstrate a bounded live reader. Disconnecting leaves the agent running.
const signal = AbortSignal.timeout(30_000);
const stream = await call('/sessions/' + session.id + '/events', { offset: null }, signal);
const decoder = new TextDecoder();
try {
  for await (const chunk of stream.body) process.stdout.write(decoder.decode(chunk, { stream: true }));
} catch (error) { if (!signal.aborted) throw error; }

```
