Looped Docs

TTY

The REPL as a network surface: an authenticated WebSocket that streams the agent's run live, for hosted terminals and embedded chat.

Without triggers, af run drops into an interactive REPL — but that REPL lives on the container's stdin, which a hosted platform can't reach. The tty trigger exposes the same interactive conversation over a WebSocket, so a control plane can attach a browser terminal to a deployed agent and the operator can chat with it live: every step, tool call and result streams as it happens.

triggers:
  - type: tty
    # path: /tty         (default)
    # port: 8090         (default)
    token_env: TTY_TOKEN   # required — bearer auth, deny by default

Because it's a trigger like any other, it composes: an agent with tty alongside discord or cron is still a long-lived service, and the terminal is a window into that same agent — same memory, same permissions, same audit trail.

The protocol

Connect with a WebSocket to ws://host:8090/tty. Auth is the usual bearer token, presented one of two ways:

  • an authorization: Bearer <token> header, for server-side clients;
  • the WebSocket subprotocol bearer.<token>, for browsers (which cannot set headers on a WebSocket). The server selects the subprotocol back on the upgrade.

Every frame in both directions is JSON. On connect the server announces itself:

{"type": "hello", "handle": "task-bot", "name": "Juniper", "description": "Triages inbound support tickets", "conversation_id": "8b1f..."}

handle is the operator's handle for the agent; name is the name the agent chose for itself on first boot (it falls back to the handle until that ritual runs), and description is its job. A client can label the agent by name instead of the raw handle, which is what Looped Meet shows for an agent in a call. description is omitted when the agent has none. Older clients that only read handle keep working, since the extra fields are additive.

Send input:

{"type": "input", "text": "what's on the calendar today?"}

Input may carry images for the turn — a screenshot, a shared-screen frame — as base64 (no data: prefix), at most 4 per turn:

{"type": "input", "text": "what's on my screen?", "images": [{"mediaType": "image/jpeg", "data": "<base64>"}]}

While the run executes, the server streams its progress — the same events the local TUI renders:

{"type": "step", "n": 1}
{"type": "assistant", "content": "Let me check."}
{"type": "tool_call", "name": "run_bash", "arguments": "{...}"}
{"type": "tool_result", "name": "run_bash", "content": "...", "durationMs": 412}
{"type": "result", "status": "ok", "reply": "Two meetings: ...", "steps": 2}

result ends the turn; send the next input after it. One run at a time per socket — an input sent mid-run gets {"type": "error", ...} back rather than queueing.

Cancelling a run

While a run is in flight you can stop it. Send:

{"type": "cancel"}

This fires the same abort the /stop command does. The run halts at its next step boundary and ends with {"type": "result", "status": "aborted", ...}, the normal terminal frame, so a client that streams until result needs no new handling beyond the added status. The socket takes the next input right after, no reconnect. A cancel with nothing running is a no-op.

This is what a barge-in looks like from the other side: a participant tells a meeting agent to stop, and the bridge sends cancel instead of dropping the connection. The abandoned run stops burning tokens and stops running tool calls the moment it reaches a step boundary, rather than executing to completion with its reply going nowhere.

Sessions

Each connection is a conversation. By default a new socket gets a fresh conversation_id; pass ?conversation_id=<id> in the URL to resume one across reconnects (with memory.scope: threadMemory), so a dropped connection or a page reload picks up where it left off.

Agent-created schedules deliver into the terminal too: a reminder created in a tty conversation arrives as {"type": "message", "text": "..."} on any socket attached to that conversation. If no terminal is attached when it fires, delivery falls through to the agent's other triggers as usual.

Exposure

token_env is required — an unauthenticated terminal contradicts deny-by-default, and this surface can drive everything the agent is permitted to do. The token resolves at startup; a missing env var fails right there. As with the webhook trigger, put a TLS-terminating proxy in front of the port before exposing it (wss://), and treat the token like the credential it is.

Every turn lands in the agent's run history with its status, steps and tokens, exactly like a run from any other trigger.

Are you an AI? Visit llms.txt — these docs as plain markdown.

On this page