Streaming nativo e tela web limpa
Integre o viewport do navegador por HTTP e WebSocket, ou apresente a tela HTML limpa do mesmo computador.
Escolha como apresentar a tela
A página HTML limpa em /view?computer_id=ID é um cliente pronto para o navegador ou WebView, com login RAM. Uma interface nativa pode consumir diretamente jpeg-websocket-v1 e decodificar JPEG no componente de imagem do aplicativo, sem carregar HTML. As duas opções acompanham o mesmo computador selecionado, não uma gravação histórica da tarefa.
O transporte oferece o viewport do Chromium, sem áudio, WebRTC, vídeo codificado ou desktop completo. Um aplicativo iOS/Android e sua renderização, armazenamento seguro, latência e ciclo de vida precisam de validação própria; este guia não afirma que um app nativo já foi validado.
Autentique e selecione o computador
Use uma chave runner da própria conta, mantida no host confiável ou armazenamento seguro do app. task_observer não autoriza sessão visual. Descubra computadores por GET /api/computers , escolha explicitamente o campo id e confira disponibilidade. Não envie proprietário, host ou URL de executor; CDP, VNC e o executor ficam privados.
Fora de loopback local, use HTTPS/WSS. A sessão por cookie da tela HTML exige login próprio e origem autorizada; uma chave da API não faz login no WebView e não deve ser injetada no HTML, no link ou na conversa.
Crie uma sessão de observação
Envie POST https://remote-agent-machine.carneiro.ai/api/visual/sessions com Authorization: Bearer <chave runner>, Content-Type: application/json e X-RAM-Intent: visual-session. O corpo abaixo seleciona explicitamente o computador e começa sem controle humano.
A resposta HTTP 201 contém version, transport, session_id, ticket, websocket_path, subprotocol, ticket_protocol_prefix, mode, viewport, max_fps, connect_expires_in_seconds e session_max_seconds. Uma falha não autoriza assumir que a conexão foi criada.
{
"computer_id": "SEU_COMPUTADOR",
"mode": "observe",
"transport": "jpeg-websocket-v1"
}Contrato da resposta HTTP 201
A resposta é um objeto JSON, sem envelope session. Todos os campos abaixo estão presentes. mode é observe ou control conforme o pedido; max_fps é inteiro entre 1 e 10, conforme configuração. viewport é um objeto com width e height numéricos inteiros, atualmente 1280 e 720. version é número 1, não string.
session_id é UUID e ticket é uma string aleatória base64url de 43 caracteres, formada por A–Z, a–z, 0–9, _ e -. O ticket não é um identificador de tarefa nem uma chave persistente. O marcador no exemplo representa um valor efêmero da resposta e não é um ticket válido; nunca publique a resposta real integral.
{
"version": 1,
"transport": "jpeg-websocket-v1",
"session_id": "11111111-1111-4111-8111-111111111111",
"ticket": "<ticket efêmero retornado pelo servidor>",
"websocket_path": "/api/visual/stream",
"subprotocol": "ram-jpeg-v1",
"ticket_protocol_prefix": "ram-ticket.",
"connect_expires_in_seconds": 60,
"session_max_seconds": 900,
"max_fps": 4,
"viewport": { "width": 1280, "height": 720 },
"mode": "observe"
}Conecte o WebSocket com ticket de uso único
Resolva websocket_path na mesma origem configurada e converta HTTPS para WSS; HTTP para WS é somente desenvolvimento em loopback. O caminho atual é /api/visual/stream. Envie os dois subprotocolos ram-jpeg-v1 e ram-ticket.<ticket>. O servidor seleciona apenas ram-jpeg-v1.
O ticket expira em 60 segundos e permite uma tentativa de conexão. Ele vai no subprotocolo, nunca na URL, nos logs ou em armazenamento persistente. A sessão dura no máximo 900 segundos. A chave runner autentica a criação HTTP; não substitui o ticket no handshake.
A biblioteca WebSocket nativa precisa responder aos frames Ping com Pong. O gateway envia Ping a cada dois segundos; bibliotecas como ws respondem automaticamente. Ping/Pong são frames do protocolo WebSocket, não mensagens JSON da aplicação.
Associe metadados e bytes JPEG
Mensagens de texto são JSON. hello informa versão, transporte, modo, FPS máximo e viewport. Cada frame informa sequence, generation, mime_type, width, height, control, controlling e url; a mensagem binária seguinte contém os bytes JPEG desse frame, sem base64 nem envelope JSON.
Associe cada JPEG ao frame anterior e descarte imagens antigas quando a UI não acompanhar. Use width e height para escala e coordenadas; o viewport padrão é 1280 × 720. generation identifica o computador em execução: se mudar, a sessão termina e o cliente deve voltar a observar com uma conexão nova. Conteúdo de tela e url podem ser privados; não registre os frames indiscriminadamente.
state comunica controle/disponibilidade; ack confirma a sequence de uma entrada processada; error encerra uma situação insegura. controlling só concede autoridade ao socket que recebeu a confirmação, não a outro observador.
{
"type": "frame",
"sequence": 1,
"generation": "GERACAO_DO_EXECUTOR",
"mime_type": "image/jpeg",
"width": 1280,
"height": 720,
"control": "available",
"controlling": false,
"url": "https://example.com/"
}hello e tipos dos frames
hello tem exatamente os campos do exemplo: type hello, version 1, transport jpeg-websocket-v1, mode observe ou control, max_fps inteiro de 1 a 10 e viewport {width:1280,height:720}. Não traz ticket, session_id ou generation; a geração chega no frame.
No frame, sequence é um contador numérico inteiro positivo do gateway, compartilhado entre capturas e independente das entradas humanas; generation é string UUID do executor. mime_type é image/jpeg; width e height são inteiros 1280 e 720 nesta versão. control do frame aceita available, leased, transition ou human; controlling é boolean e url é string da página, podendo ser about:blank. Não exija URL HTTP(S) para aceitar metadados de frame.
{
"type": "hello",
"version": 1,
"transport": "jpeg-websocket-v1",
"mode": "control",
"max_fps": 4,
"viewport": { "width": 1280, "height": 720 }
}Variantes de state, ack e error
state não tem um único conjunto de campos obrigatórios. Há quatro variantes atuais, mostradas como mensagens separadas abaixo: transition omite controlling; human inclui controlling true e três contadores inteiros não negativos; released e unavailable incluem controlling false. Os contadores registram processos parados, downloads cancelados e exports abortados. Não presuma que todo state contém controlling, generation ou sequence.
O conjunto de control nas mensagens frame/state é available, leased, transition, human, released ou unavailable. Um state transition só anuncia preparo, não concede controle. Um state unavailable informa falha de captura; um observador pode continuar aberto, enquanto a sessão humana é encerrada. state released confirma devolução; ainda é necessário observar/conferir antes da continuação explícita.
ack contém somente type ack e sequence numérica inteira positiva da entrada concluída. error contém somente type error e message string legível; não há code, retry_after ou enum estável de motivos. Uma mensagem error pode ser seguida de fechamento, e pode nem chegar se o socket estiver congestionado. Trate também close/error da biblioteca.
{ "type": "state", "control": "transition" }
{
"type": "state", "control": "human", "controlling": true,
"stopped_processes": 0,
"cancelled_downloads": 0,
"aborted_exports": 0
}
{ "type": "state", "control": "released", "controlling": false }
{ "type": "state", "control": "unavailable", "controlling": false }
{ "type": "ack", "sequence": 1 }
{ "type": "error", "message": "Computer restarted; reconnect." }Exemplo de observação sem HTML
O exemplo usa fetch nativo do Node.js 24 e a biblioteca pública ws. No seu próprio projeto, instale npm install --save-exact ws@8.22.0 e salve o exemplo em um arquivo .mjs; não é necessário acessar o repositório RAM. O app chama observeComputer com baseUrl desta instalação, computerId selecionado, uma função credential que fornece sua chave runner em privado e callbacks renderJpeg/onDisconnect. Nenhum token fictício concede acesso real.
A função cria o ticket por HTTP e abre o WebSocket usando somente o protocolo público. O app implementa o renderizador JPEG e mostra a desconexão. O exemplo não reconecta, assume controle nem reenvia entradas automaticamente.
import { WebSocket } from 'ws';
export async function observeComputer({ baseUrl, computerId, credential, renderJpeg, onDisconnect }) {
if (!computerId) throw new Error('Selecione um computador da sua conta');
const origin = new URL(baseUrl);
if (origin.username || origin.password || origin.search || origin.hash ||
origin.pathname !== '/' || (origin.protocol !== 'https:' &&
!(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))) {
throw new Error('Use uma origem HTTPS ou loopback local');
}
const response = await fetch(new URL('/api/visual/sessions', origin), {
method: 'POST',
headers: {
Authorization: `Bearer ${await credential()}`,
'Content-Type': 'application/json',
'X-RAM-Intent': 'visual-session',
},
body: JSON.stringify({ computer_id: computerId,
mode: 'observe', transport: 'jpeg-websocket-v1' }),
});
if (response.status !== 201) throw new Error('Não foi possível criar a sessão');
const session = await response.json();
const url = new URL(session.websocket_path, origin);
if (url.origin !== origin.origin || url.search || url.hash ||
url.pathname !== '/api/visual/stream') throw new Error('Endpoint inválido');
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(url, [
session.subprotocol,
session.ticket_protocol_prefix + session.ticket,
]);
socket.binaryType = 'arraybuffer';
let generation;
let pendingFrame;
socket.on('message', (data, binary) => {
try {
if (binary) {
if (!pendingFrame) throw new Error('JPEG sem metadados');
const jpeg = new Uint8Array(data);
if (jpeg.byteLength > 2_000_000) throw new Error('Frame excede o limite');
renderJpeg(jpeg, pendingFrame); // substituir a imagem anterior
pendingFrame = undefined;
return;
}
const event = JSON.parse(data.toString());
if (event.type === 'frame') {
if (typeof event.generation !== 'string' || event.mime_type !== 'image/jpeg' ||
(generation && generation !== event.generation)) {
throw new Error('Observe novamente a geração atual');
}
generation = event.generation;
pendingFrame = event;
}
if (event.type === 'error') socket.close();
} catch { socket.close(); }
});
socket.on('error', () => socket.close());
socket.on('close', () => onDisconnect?.());
// ws responde a Ping com Pong automaticamente; não são mensagens JSON.
// A UI mostra close/error e oferece reconexão explícita com ticket novo.
return { close: () => socket.close() };
}Alterne entre HTML e interface nativa
Para abrir a tela HTML de uma tarefa, obtenha o viewer autenticado por GET /api/runs/{id}/viewer ou MCP run_viewer. Apresente sua url somente quando a pessoa quiser acompanhar. Sem viewer autorizado, informe a indisponibilidade; não fabrique link substituto. A atribuição atual é verificada novamente ao abrir.
Ao trocar de view, feche o socket anterior para evitar sessões duplicadas. Se estiver sob controle humano, devolva o controle e confira sua liberação antes de agir por outro cliente. Abra a nova view para o mesmo computer_id em observe, com ticket novo no modo nativo ou sessão web autenticada no HTML. A troca não transfere cookies, ticket, concessão humana ou sequência de entradas entre clientes e não continua uma tarefa pausada.
Após desconexão ou mudança de geração, apresente Reconectar como decisão explícita, peça novo ticket e observe o estado atual. Nunca reuse o ticket anterior ou repita takeover, clique, texto ou navegação pendentes. Uma resposta perdida pode ter produzido efeito.
Controle humano é uma etapa separada
Uma sessão observe não pode assumir controle nem enviar input. Para oferecer controle, crie explicitamente uma sessão mode control e espere o primeiro frame. Só um gesto da pessoa envia {"type":"takeover"}; espere state com controlling true antes de habilitar input.
O servidor e o executor validam a concessão, tratam processos/downloads ativos e invalidam o controle anterior do agente. Inputs levam sequence inteira positiva e crescente, distinta da sequência de frames; aguarde ack e não repita uma entrada incerta. {"type":"release"} devolve controle; continuação do runner é outro pedido explícito.
Envelope de entrada e confirmação
Envie cada entrada como mensagem de texto JSON com somente type, sequence e input. type é input; sequence é um inteiro de 1 a 9007199254740991, estritamente crescente na concessão humana deste socket. Os objetos são fechados: campos extras são recusados. Nos blocos abaixo, cada objeto é uma mensagem separada; o bloco inteiro não é um array nem uma única mensagem JSON. Não envie computer_id, generation, lease_id, proprietário ou segredo no envelope; o gateway usa o vínculo autenticado da sessão.
A resposta {"type":"ack","sequence":1} confirma que a entrada 1 terminou no executor. Ela não comprova conclusão de uma tarefa, login ou efeito empresarial. Aguarde o ack correspondente antes da próxima entrada e confira o frame atual. Cliques e teclas usam duas entradas separadas, down e up, cada uma com sua própria sequence e ack.
Após erro, fechamento ou perda de ack, desabilite entradas e apresente o resultado como incerto. O servidor não reenvia a entrada. Sequência repetida/menor, formato inválido ou concessão ausente/expirada encerram a sessão; não aumente sequence para repetir a mesma intenção. Reabra somente por decisão explícita, com ticket novo, e observe antes de qualquer nova ação.
Clique e movimento no viewport
input.type pointer aceita phase move, down ou up; x é número de 0 a 1279 e y de 0 a 719. button aceita left, middle ou right, com padrão left quando omitido. Um clique usa down e depois up, na mesma posição, aguardando cada ack. Não existe input.type click.
Converta o gesto na imagem exibida para coordenadas do viewport, descontando margens da imagem: x = (posição horizontal − margem esquerda) × width / largura exibida; y usa height / altura exibida. Ignore gestos fora da imagem. O contrato atual limita o viewport a 1280 × 720; não envie coordenadas da tela física do celular.
{
"type": "input",
"sequence": 1,
"input": {
"type": "pointer", "phase": "down",
"x": 420, "y": 180, "button": "left"
}
}
{
"type": "input",
"sequence": 2,
"input": {
"type": "pointer", "phase": "up",
"x": 420, "y": 180, "button": "left"
}
}Texto e teclado
input.type text aceita text como string com até 4000 unidades UTF-16 (o comprimento de string em JavaScript), inclusive vazia. Insere texto no elemento atualmente focado; não seleciona um campo nem envia Enter. É indicado para texto/IME. Não registre o conteúdo digitado.
input.type key exige phase down ou up e key: uma string de comprimento 1 em UTF-16, ou exatamente Backspace, Tab, Enter, Escape, Delete, Home, End, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, PageUp, PageDown, Shift, Control, Alt, Meta, F1 a F12. Combinações usam entradas individuais de pressão/liberação dos modificadores e da tecla; não use strings como Control+A. Libere as teclas; cada entrada precisa de sequence nova e ack.
{
"type": "input", "sequence": 3,
"input": { "type": "text", "text": "Olá" }
}
{
"type": "input", "sequence": 4,
"input": { "type": "key", "phase": "down", "key": "Enter" }
}
{
"type": "input", "sequence": 5,
"input": { "type": "key", "phase": "up", "key": "Enter" }
}Rolagem e navegação
input.type wheel exige delta_x e delta_y numéricos, cada um entre −2000 e 2000. A roda atua na posição atual do ponteiro; posicione-o com pointer/move quando precisar direcionar a rolagem. Não existe input.type scroll.
input.type navigate exige url absoluta HTTP ou HTTPS, com até 4096 caracteres e sem usuário/senha embutidos. O executor espera DOMContentLoaded com limite de 7 segundos; erro ou timeout pode ocorrer depois de iniciar a navegação. Confira o resultado observado sem repetir automaticamente.
{
"type": "input", "sequence": 6,
"input": { "type": "wheel", "delta_x": 0, "delta_y": 400 }
}
{
"type": "input", "sequence": 7,
"input": { "type": "navigate", "url": "https://example.com/" }
}Adaptador mínimo para gestos do app
Este trecho complementa a conexão HTTP/WebSocket anterior: socket é um ws já aberto com ticket novo de sessão mode control. Mantenha a associação JSON/JPEG e a conferência de geração do exemplo de observação. O app chama takeover, input e release somente a partir de gestos explícitos da pessoa; não há comando automático ao conectar. O trecho controla a fila de uma entrada e o deadline finito escolhido em waitMs; não implementa renderizador ou app móvel.
state com control human e controlling true habilita entrada; state released com controlling false confirma a devolução. Um frame não transfere autoridade de outro socket. error.message é explicação legível, não um código estável para decidir retry; mesmo sem receber error, close exige nova inspeção.
function bindHumanGestures(socket, onState, waitMs) {
if (!Number.isInteger(waitMs) || waitMs < 1 || waitMs > 2147483647) throw new Error("Escolha waitMs entre 1 e 2147483647");
let timer;
const clearDeadline = () => { clearTimeout(timer); timer = undefined; };
let observed = false, controlling = false;
let sequence = 0, pending, transition;
const send = value => {
if (socket.readyState !== 1) throw new Error("Socket fechado");
socket.send(JSON.stringify(value));
};
const stop = () => {
clearDeadline(); controlling = false;
// Se pending existe, o resultado dessa entrada é incerto.
onState({ controlling: false, uncertain: pending !== undefined || Boolean(transition) });
};
const armDeadline = () => {
clearDeadline();
timer = setTimeout(() => { stop(); socket.close(); }, waitMs);
};
socket.on("message", (data, binary) => {
if (binary) return; // JPEG tratado pelo renderizador separado.
try {
const event = JSON.parse(data.toString());
if (event.type === "frame") {
observed = true;
if (event.controlling !== true) controlling = false;
}
if (event.type === "state") {
if (event.controlling === true && event.control === "human") {
if (transition === "takeover" && pending === undefined) {
clearDeadline();
sequence = 0; controlling = true; transition = undefined;
} else if (!controlling) throw new Error("Concessão inesperada");
} else {
controlling = false;
if (event.control === "released" && transition === "release") {
clearDeadline(); transition = undefined;
}
}
}
if (event.type === "ack" && event.sequence === pending) {
clearDeadline(); pending = undefined;
}
if (event.type === "error") { stop(); socket.close(); return; }
onState({ controlling, awaitingAck: pending !== undefined });
} catch { stop(); socket.close(); }
});
socket.on("error", () => { stop(); socket.close(); });
socket.on("close", stop);
return {
takeover() {
if (!observed || controlling || transition || pending !== undefined) throw new Error("Observe primeiro");
transition = "takeover"; armDeadline();
try { send({ type: "takeover" }); }
catch { stop(); socket.close(); throw new Error("Takeover incerto; confira"); }
},
input(value) {
if (!controlling || pending !== undefined) throw new Error("Aguarde controle e ack");
pending = ++sequence; armDeadline(); // Sem replay após timeout.
try { send({ type: "input", sequence, input: value }); }
catch { stop(); socket.close(); throw new Error("Resultado incerto; confira"); }
},
release() {
if (!controlling || pending !== undefined) throw new Error("Aguarde o ack");
controlling = false; transition = "release"; armDeadline();
try { send({ type: "release" }); }
catch { stop(); socket.close(); throw new Error("Liberação incerta; confira"); }
// Aguarde state released antes de trocar de cliente.
}
};
}Prazos do servidor e deadlines do cliente
O ticket vence 60 segundos após emissão; o socket tem limite de 900 segundos a partir da conexão. A concessão humana no executor dura até 8 segundos, podendo terminar antes pelo prazo de uso autorizado, e o gateway tenta renová-la no ciclo de 2 segundos. São prazos de validade, não tempos prometidos de resposta.
O heartbeat envia Ping a cada 2 segundos. A conexão é encerrada no próximo ciclo que encontra o Pong anterior ausente. A agenda do event loop e a rede afetam a hora observada; isso não é uma garantia de desconexão em exatamente 2 segundos nem um timer JSON. Responda Pong pela biblioteca WebSocket.
Não existe deadline público ponta a ponta fixo para ack, takeover ou release. As chamadas internas gateway→executor têm timeout padrão de 25 segundos por chamada, e navigate tem 7 segundos no executor; fila, verificações de autorização, medição e limpeza envolvem outras esperas. Esses limites internos não garantem que o cliente receba ack/state nesse prazo.
Escolha no cliente um limite explícito, finito e positivo para criação HTTP, handshake, primeiro frame, ack e confirmação de takeover/release. É uma política do cliente, não campo do protocolo nem SLA do RAM. Ao vencer após envio de ação, desabilite input, encerre a conexão e explique resultado incerto; não retransmita. Confira o estado numa reconexão explícita com ticket novo antes de decidir outra ação. O adaptador de gestos recebe waitMs inteiro escolhido pelo app, entre 1 e 2147483647 milissegundos (limite do timer JavaScript), para cada ack/transição; a conexão/primeiro frame e o fetch também precisam de limites próprios.
Fechamento e códigos WebSocket
O gateway não define códigos próprios de fechamento por motivo. Seu caminho de falha usa terminate, sem enviar um frame Close explicativo; o cliente pode observar 1006, encerramento anormal, que não é um código transmitido no fio. A biblioteca também pode fechar por violações do protocolo, como 1009 para payload excessivo; proxies e o cliente podem produzir outros códigos.
Não interprete um close code como recibo de release, ausência de efeito, motivo de saldo ou autorização para replay. Nem 1000 comprova que o controle foi liquidado. Uma criação HTTP recusada devolve JSON {error: string}: 400 para pedido inválido, 401 sem login, 403 para acesso/origem recusados, 409 sem computador próprio pronto e 429 por limite de sessões. Handshake não autorizado retorna HTTP 401 e não cria uma sessão aberta. Essas recusas não autorizam retry de uma entrada já enviada em outro socket.
Limites do transporte atual
O máximo anunciado em max_fps é configurado pelo servidor: padrão 4 FPS, faixa de 1 a 10. JPEG de qualidade 65, até 2.000.000 bytes por frame. Até quatro sockets simultâneos e 32 tickets pendentes por gateway; isso não é uma quota de escala validada para clientes móveis.
Mensagens de controle têm até 16.000 bytes, fila até 16 entradas e limite de 100 mensagens por segundo. O gateway descarta oportunidades de frame com 1.000.000 bytes pendentes e encerra a conexão acima de 4.000.000. O cliente deve manter uma fila de renderização limitada.
A concessão humana expira em oito segundos e é renovada pelo gateway. Perda de heartbeat, expiração da sessão, revogação da chave, mudança de atribuição/geração e falha insegura exigem fechamento e nova inspeção. Não há reconexão com replay.
Perguntas frequentes
Preciso carregar HTML para mostrar a tela no app?
Não. O app pode usar HTTP para emitir um ticket e WebSocket para receber JSON e JPEG binário. A tela HTML limpa continua sendo uma alternativa com login próprio.
Um link viewer ou task_observer autentica o streaming nativo?
Não. A criação da sessão visual exige credencial apropriada da própria conta e computador atribuído. O link viewer exige login web; task_observer não concede acesso visual.
A troca de interface retoma automaticamente o agente?
Não. Feche a conexão anterior, confirme a liberação do controle e abra a nova view observando. Retomar uma tarefa ou assumir controle exige ação explícita.
Esta documentação é pública e não concede acesso à conta. Confirme capacidades e permissões na instalação autenticada. Segredos não pertencem à conversa do agente.
