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
- On the remote instance, connect your native provider account in AI Usage.
- 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.
- Copy the token from its one-time reveal and the displayed Base URL.
- 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….
- 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 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.
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
The same owner API keeps files, history and previews on A while the saved B connection runs the agent.
// 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
Use A’s owner session credential in a private backend. B’s gateway token is already saved on A and cannot replace this owner credential. Select the catalogue values and retain one launch intention UUID.
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
Node.js 22 or later. Set AIRCODE_REMOTE_BASE_URL and AIRCODE_REMOTE_TOKEN in private server configuration. The first run prints the catalogue until you also choose AIRCODE_REMOTE_ENGINE, AIRCODE_REMOTE_MODEL and AIRCODE_REMOTE_PERMISSION. AIRCODE_REMOTE_EFFORT is optional. The reader closes after 30 seconds; the remote agent remains running.
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; }