Architecture

Yuna is pull-based, stateless, and fire-and-forget. The orchestrator never opens a connection to your device. Communication flows through Redis Streams as a broker and long-poll HTTPS as pseudo-push. This is the same pattern GitHub Actions runners and Celery workers use — it just happens to be wrapped around a Claude agent loop.

The end-to-end flow

Telegram                    Vercel                     Redis                   Device
    ────────                    ──────                     ─────                   ──────
    user msg  ─── webhook ───▶  orchestrator
                                   │
                                   ├─▶ Claude API (tool_use)
                                   │
                                   │◀── tool_use: run_on_{dev}
                                   │
                                   ├─── risk gate ─── risky? ─── Telegram ⚠️
                                   │                              (await 👍/❌)
                                   │
                                   └─── XADD ───────▶  stream:{dev}
                                                          │
                                                          │◀─── XREADGROUP (long-poll, 25s)
                                                          │
                                                                                    device
                                                                                    executes
                                                                                    bash
                                   ┌────── POST /respond ◀─────────────────────────── output
                                   │
                                   ├─▶ Claude API (tool_result)
                                   │
                                   │◀── final text
                                   │
    reply    ◀── sendMessage ───  ┘

Components

Server (Next.js on Vercel)

  • lib/orchestrator.ts — the Claude agent loop. Holds the conversation, builds tools dynamically, dispatches commands, resumes on tool_result.
  • lib/tools.ts — generates run_on_{device}, read_file, write_file, and (conditionally) transfer_file tool definitions from the device registry on every Claude call.
  • lib/system-prompt.ts — builds a dynamic system prompt with one section per registered device (status, OS, description, capabilities). Tells the model that <tool_output> content is untrusted.
  • lib/risk.ts — regex classifier for destructive commands (rm -rf, dd, sudo, force-push, mkfs, eval, etc.). Flags go through the confirmation gate.
  • lib/redis.ts — typed helpers over Upstash Redis for streams, conversation, orchestration tasks, audit log, pending confirmations.
  • lib/devices.ts — device registry CRUD with online status (heartbeat window 60s).
  • lib/auth.ts — per-device UUID tokens, one-time setup codes, hashed master secret, Telegram owner lock.
  • API routes: /api/telegram/webhook, /api/relay/poll, /api/relay/respond, /api/relay/register, /api/devices, /api/health.

Device agent

  • agent.ts — polling loop. Long-polls /api/relay/poll with a per-fetch AbortController to avoid leaking listeners on the shutdown signal. Exponential backoff on network failure (1s → 30s cap).
  • executor.ts — bash / read_file / write_file / transfer_file. Per-command timeout (default 60s, max configurable via timeout_seconds in the tool input). Output truncated at 8 KB with head+tail preservation.
  • Runs on any Linux or macOS box with Node 18+. Only needs outbound HTTPS to your Vercel URL — no inbound ports, no Tailscale required.

Redis keys

KeyTypePurpose
yuna:devicesSETRegistered device names
yuna:device:{name}HASHDevice metadata (os, description, capabilities, ssh)
yuna:token:{token}STRINGPer-device auth token → device identity
yuna:device-token:{name}STRINGReverse index for token revocation
yuna:setup-code:{code}STRINGOne-time device setup code (10-min TTL)
yuna:lastseen:{name}STRINGDevice heartbeat timestamp
yuna:stream:{name}STREAMPer-device command queue, consumer group agent
yuna:conversation:messagesSTRINGShared conversation history (JSON)
yuna:orchestration:{taskId}STRINGIn-flight agentic task state (5-min TTL)
yuna:pending-confirm:{msgId}STRINGRisky command awaiting 👍/❌ reaction (5-min TTL)
yuna:logLISTAudit log, capped at 1000 entries
yuna:masterSTRINGHashed master secret

Wire protocol

The orchestrator serializes a command to the device stream as a flat field map (Redis Streams don't support nesting):

{
  "type": "command",
  "taskId": "uuid",
  "tool": "run_on_uconsole",
  "input": "{\"command\":\"df -h\",\"timeout_seconds\":60}",
  "chatId": "123456",
  "messageId": "42",
  "payload": "...",
  "timestamp": "2026-04-13T03:30:00.000Z"
}

The device responds via POST:

POST /api/relay/respond
Authorization: Bearer <device-token>

{
  "taskId": "uuid",
  "output": "<bash stdout+stderr>",
  "exitCode": 0,
  "streamId": "1234567890-0"
}

The orchestrator then ACKs the stream entry, appends the output to the orchestration task (wrapped in <tool_output> delimiters), and feeds the batch back to Claude as tool_result blocks.

Why this architecture

  • Devices are stateless and firewall-friendly. They only make outbound HTTPS. No VPN, no port forwarding, no Tailscale required. Works from coffee shops and cell data without any extra setup.
  • The server is stateless too. Every request to Vercel loads conversation + orchestration state from Redis, does its work, and returns. Vercel can scale to zero between invocations — you only pay for actual traffic.
  • Redis Streams give guaranteed delivery. Consumer groups with XACK mean a command is either processed or reclaimed if the device crashes mid-execution. No silent drops.
  • Dynamic tools mean zero code changes per device. Adding a device is a runtime operation. Claude's tool list is regenerated from the registry on every call, and the system prompt describes each device so the model can route intelligently.