Skip to content

Companion & Gateway

The Companion is a local HTTP server that runs alongside Gee-Code. It lets the Gee platform reach into your machine — reading files, running commands, and delivering webhooks — even when no REPL session is open. The Communication Gateway builds on top of it, routing SMS, email, and voice between you and your Gees.

When a Gee runs autonomously (via the daemon), it sometimes needs to:

  • Receive inbound webhooks from long-running jobs (video generation, workflow results)
  • Accept SMS or email replies from the user
  • Let the Gee platform execute local file and shell operations

Without the companion, autonomous Gees would be isolated — they could send messages out, but nothing could reach them. The companion closes the loop.

You (SMS/Email)
Gee WebApp (to.gee.page)
▼ WebSocket tunnel (/companion namespace)
┌────┴────────────────────────────┐
│ Companion Server (port 7778) │
│ │
│ /health Health check │
│ /execute MCP tools │
│ /read_file File access │
│ /run_command_stream Shell │
│ /webhook/fal/* FAL results │
│ /webhook/agentic/* Workflows │
│ /webhook/gateway_reply Replies│
│ /webhook/gateway_sms SMS │
│ /ws/terminal Interactive │
└─────────────────────────────────┘

The flow works in two directions:

  1. Outbound (Gee → You): A Gee calls SendNotification → the gateway client POSTs to gee-webapp → Twilio sends you an SMS or email
  2. Inbound (You → Gee): You reply via SMS → Twilio webhook → gee-webapp → WebSocket tunnel → companion server → Gee’s inbox → daemon triggers the Gee

The companion is an aiohttp web server that binds to 127.0.0.1:7778 by default. It implements the Desktop Companion API — the same protocol used by The Terminal’s GUI companion — so the backend treats both identically.

EndpointPurpose
GET /healthHealth check — returns service type and session token
GET /toolsMCP tool discovery (list available local operations)
POST /executeExecute an MCP tool by name
POST /read_fileRead a local file
POST /write_fileWrite to a local file
POST /list_directoryList directory contents
POST /run_command_streamExecute a command with SSE streaming output
POST /MCP JSON-RPC endpoint (tools/list, tools/call)
GET /ws/terminalWebSocket for interactive PTY terminal sessions

The companion exposes five MCP tools to the backend:

ToolDescription
local_read_fileRead file contents (up to 10 MB)
local_write_fileWrite content to a file
local_list_directoryList directory entries
local_delete_fileDelete a file (creates timestamped backup)
local_run_commandExecute a shell command (300s timeout, blocklist enforced)

A blocklist prevents destructive commands (rm -rf /, mkfs, dd if=) from executing through the companion.

The server tries port 7778, then 7779, then 7780. This avoids conflicts when The Terminal’s GUI companion is already running on 7777, and allows multiple gee-code instances to coexist when needed.

Multiple gee-code sessions share a single companion server:

  1. The first session starts the server and writes a state file to ~/.gee-code/companion_state.json
  2. Subsequent sessions detect the running companion via a health check
  3. All sessions share the tunnel URL registered with the backend
  4. When the owner session exits, it clears the state file — the next session takes over

The state file has a 5-minute TTL. If the owner process crashes without cleanup, other sessions detect the stale state and start fresh.

The companion server only listens on 127.0.0.1 — it is not directly reachable from the internet. A WebSocket tunnel bridges this gap.

  1. Gee-Code connects to to.gee.page via Socket.IO on the /companion namespace
  2. The server assigns a proxy URL: https://to.gee.page/companion/{companion_id}/proxy
  3. When gee-webapp needs to reach the companion, it emits a proxy_request event through the WebSocket
  4. The tunnel forwards the request to 127.0.0.1:7778, gets the response, and emits proxy_response back
  5. Gee-webapp returns the response to the original caller

This is a reverse proxy over WebSocket — no port forwarding, no ngrok, no firewall changes.

The tunnel is built for reliability:

  • Auto-reconnect — unlimited retries with exponential backoff (1s to 30s delay)
  • Request deduplication — multi-worker webapp deployments can emit duplicate proxy_request events; the tunnel deduplicates by request_id so local side effects run exactly once
  • Status tracking — the tunnel reports connected, disconnected, or reconnecting status, visible in The Terminal’s Observability panel

The tunnel also supports full PTY terminal sessions over Socket.IO:

EventDirectionPurpose
terminal_startServer → ClientStart a new terminal (cols, rows)
terminal_startedClient → ServerConfirm terminal is running
terminal_inputServer → ClientKeyboard input from the user
terminal_outputClient → ServerTerminal output (stdout)
terminal_resizeServer → ClientWindow resize
terminal_killServer → ClientKill the terminal process
terminal_exitClient → ServerProcess exited (with exit code)

This powers The Terminal’s remote shell feature — a browser-based terminal that runs commands on your local machine through the tunnel.

The companion accepts webhooks for long-running operations that complete asynchronously.

When a Gee generates media through FAL (images, video, audio), it can register a webhook URL. When the job completes, FAL POSTs the result to:

POST /webhook/fal/{request_id}

The companion resolves a pending future (if the Gee is waiting synchronously) or invokes a background callback (if the job was fire-and-forget).

Visual workflows (Agenticss) that run on the Gee platform report completion to:

POST /webhook/agentic/{request_id}

Same resolution pattern — pending future or background callback.

Approval responses from the communication gateway arrive as:

POST /webhook/{request_id}

When the payload includes type: "approval_response", the companion resolves the corresponding ApprovalRequest so the Gee can continue.

The gateway is how Gees talk to humans and how humans talk back to Gees.

A Gee sends messages using the SendNotification tool, which calls GatewayClient under the hood:

ChannelMethodDelivery
SMSsend_sms()Via Twilio through gee-webapp
Emailsend_email()Via gee-webapp email service
Voiceinitiate_call()Via Twilio voice through gee-webapp

Messages include the Gee’s name as a prefix (e.g., [analyst] Build failed on main) and a reply code for routing responses back. Identity resolves through a chain: explicit gee_name parameter → environment variables (for BYOP/CLI providers) → in-process mode context → "gee" fallback.

When a user sends an SMS, the Gee might take 10–30 seconds to formulate a full response — it needs to read the message, load context, call tools, and compose a reply. During that silence, the user has no idea whether their message was received.

The timed ack solves this. When an inbound SMS arrives, a 5-second timer starts. If the Gee’s real reply hasn’t been sent by then, a short contextual acknowledgement is sent automatically:

You: "What's the deployment status?"
↓ 5 seconds, no reply yet
[og] "Got your question about the deployment, looking into it now."
↓ 15 seconds later
[og] "Staging deployed at 14:22, all health checks green. Production queued for 16:00."

If the Gee replies within 5 seconds, the timer is cancelled and the user only sees the real answer — no redundant ack.

How it works:

  1. Inbound SMS arrives at the companion server
  2. _start_delayed_ack() creates an async timer for that Gee
  3. The timer sleeps for 5 seconds, then generates a contextual one-sentence ack using a fast model (Haiku)
  4. When the Gee’s real reply fires via SendNotificationsend_sms(), cancel_pending_ack() kills the timer
  5. If the fast model is unavailable, a static fallback ack is sent instead

The ack is contextual — it references what the user asked about without answering it, so the user knows their message was received and is being worked on. Each Gee has its own independent timer, so multiple Gees processing messages simultaneously don’t interfere with each other.

When you reply to a Gee’s message, the response routes back through two paths:

POST /webhook/gateway_reply

Gee-webapp forwards your reply to the companion with a payload containing gee_name, message, and channel. The companion:

  1. Writes the message to the target Gee’s inter-mode inbox (comms/inbox.jsonl)
  2. Triggers the Gee via the daemon so it reads and responds immediately

When an inbound SMS arrives, the gateway routes it through a four-step fallback cascade. Each step tries to identify the right Gee; if it fails, the message falls through to the next step.

Inbound SMS
[1] Approval response check
│ (YES/NO to pending approval?)
[2] Reply-code prefix
│ (TP, CR, etc.)
[3] Gee-name prefix
│ (og, analyst, etc.)
[4] LLM intent classifier
│ (reads recent conversation thread)
[5] Prime Gee fallback
│ (designated default Gee)
[6] Default webapp Gee

Step 1 — Approval responses. Before routing as a normal message, the system checks whether the SMS answers a pending approval. Patterns like TP-YES, YES ref:abc123, or a bare YES are matched against active approval requests. If found, the approval resolves and the Gee unblocks.

Step 2 — Reply-code prefix. Users can prefix a message with a reply code (e.g., TP Add a day in Rome). The code maps to a Gee name via Redis, and the message is forwarded with the code stripped.

Step 3 — Gee-name prefix. You can address a Gee directly by name:

og check the deployment status
analyst run the weekly report

The gateway tries an exact match against known Gee names, then falls back to fuzzy matching with a Levenshtein distance of 1. Only single unambiguous matches route — if two Gees fuzzy-match, the message falls through.

Step 4 — LLM intent classifier. For unaddressed messages, the system reads the recent conversation thread — a sliding window of the last 6 messages within a 10-minute window. If a Gee recently sent the user something, the classifier (a fast model call) determines whether the new message is a reply to that Gee. High and medium confidence matches route automatically; low confidence falls through.

Step 5 — Prime Gee. If no routing matched and a prime Gee is set, the message goes to that Gee as the designated default.

Step 6 — Default webapp. If nothing else matched (no daemon running, no prime Gee, no conversation context), the message routes to the webapp’s built-in core agent.

When a Gee initiates a voice call, it sends more than just the message. The system enriches the call with the Gee’s current objectives so the voice AI can speak with full context about what the Gee is working on.

What gets forwarded:

  • The original message (the reason for the call)
  • The Gee’s name (the voice AI identifies as this Gee)
  • Current objectives summary (so the AI knows what the Gee is tracking)

Post-call summary: When the call ends, gee-webapp sends a summary callback to the companion server. The companion writes the summary and full transcript to the originating Gee’s inbox as a voice_call_summary message. The daemon then triggers the Gee so it can process what was discussed — follow up on action items, update its state, or send a written recap.

This closes the loop: a Gee can call the user, discuss something with full context about its current work, and then act on the outcome without any manual bridging.

Gees can send structured approval requests that require a YES/NO response. The SMS routing chain handles these as a priority — approval responses are checked before any other routing. See the SMS Routing Chain for how approval replies are matched by reply code, reference ID, or recency.

When the companion receives an inbound message, it triggers the target Gee using this escalation:

  1. In-process daemon — if the daemon is running in the same process, trigger directly
  2. Cross-process daemon — if the daemon is in another process, write a trigger request file that the daemon picks up on its next check
  3. Direct fallback — if no daemon is running, activate the Gee directly

The “prime Gee” is a designated default — the Gee that receives unaddressed messages when no other routing matches. Think of it as the Gee that answers the phone when you don’t specify who you want to talk to.

Mark a mode as prime in its mode.json:

{
"name": "og",
"is_prime": true,
"autonomous": {
"guiding_mission": "Executive assistant"
}
}

Or set it interactively with the /gee browse command — press p to designate the selected Gee as prime.

Only one Gee should be prime per user. When the daemon starts, it scans all loaded modes and registers the prime Gee with the backend so the SMS routing chain can use it as a fallback.

When a Gee is busy with a long activation — running tests, generating a report, building a skill — it holds the activation lock. Any inbound messages during this time would normally queue behind the lock and wait. Orchestrator mode changes this.

With orchestrator mode enabled, inbound messages bypass the activation lock entirely. Instead of waiting, they trigger a lightweight triage call that runs outside the main lock:

Normal mode:
Inbound trigger → waits for lock → heavy LLM call → response (minutes)
Orchestrator mode:
Worker holding lock (long task running)
Inbound trigger arrives
Fast orchestrator triage (separate lock, ~5s)
Immediate reply via SendNotification
Worker continues uninterrupted

The orchestrator knows what the main worker is doing — it has access to the Gee’s background task list — so it can give informed responses like “I’m currently running the deployment pipeline, should be done in a few minutes” rather than silence.

Opt in per mode via mode.json:

{
"name": "cfo",
"autonomous": {
"guiding_mission": "Manage financial operations",
"orchestrator_mode": true
}
}

When an inbound message arrives and the main activation is locked:

  1. Acquires a separate orchestrator lock (serializes fast triage calls)
  2. Builds a concise system prompt with the Gee’s identity and current background tasks
  3. Reads the inbound message
  4. Calls a fast model to generate a reply
  5. Sends the reply via SendNotification on the same channel the message arrived on
  6. Marks inbox messages as read to prevent duplicate processing when the main worker finishes

If the fast model is unavailable, it falls back to a canned acknowledgment.

The runner maintains two independent locks:

LockPurposeTypical Hold Time
_activation_lockMain worker — full LLM activation with toolsMinutes
_orchestrator_lockTriage — fast read-and-reply calls< 5 seconds

The orchestrator lock serializes triage calls (one at a time) but never blocks behind the worker lock. This means a Gee can handle multiple rapid inbound messages without queuing, while a long activation runs uninterrupted.

The same message can arrive via multiple paths — for example, a gateway reply event followed by a heartbeat that reads the inbox. The runner tracks recently handled trigger keys with a 120-second TTL to prevent duplicate processing. If a trigger key has already been handled, the second arrival is silently dropped.

The orchestrator’s key advantage is situational awareness. It knows what the worker is doing because the runner maintains a background task registry:

  • register_background_task(task_id, description) — records a new task with a timestamp
  • complete_background_task(task_id, summary) — marks a task done with results
  • get_active_background_tasks() — returns running tasks with elapsed time

When the orchestrator responds, it includes active task descriptions and timing in its system prompt. This lets it say “I’m 3 minutes into deploying the staging environment” instead of a generic “I’m busy.”

If the LLM is unavailable (no executor configured, timeout, or error), the orchestrator falls back to a canned response that still references the current task:

I got your message. I’m currently working on: deploying staging environment. I’ll get back to you as soon as I’m done.

Task descriptions are truncated to 80 characters for clean formatting. For email fallbacks, the subject line includes Re: your message to {gee-name}.

Orchestrator triage only fires when all three conditions are true:

  • orchestrator_mode is enabled in the mode config
  • The main activation lock is held (a worker is busy)
  • The trigger is an inbound message (not a heartbeat or scheduled trigger)

If the main lock is free, the message goes through normal activation — no orchestrator needed.

When a Gee is triggered by an inbound SMS or email, it always sends a reply. This is enforced at two levels:

  1. Prompt-level mandate — The trigger prompt tells the model to respond using SendNotification on the same channel the message arrived on, and explicitly states “Do not finish this activation without sending a reply.”

  2. Fallback safety net — After the activation completes, the runner checks whether any tool actions were taken. If the model completed silently (zero actions), the system automatically sends a fallback reply:

    I got your message: “check the deployment status”. I’m on it.

    The incoming message is condensed to 140 characters and quoted back, so the user knows their message was received even if the Gee couldn’t formulate a full response.

This guarantee exists because autonomous Gees can occasionally fail to produce tool calls — model errors, context limits, or unexpected edge cases. Without the safety net, an SMS would disappear into a void with no acknowledgment. The fallback ensures the communication loop always closes.

For email replies, the fallback includes a Re: subject line with the Gee’s name (e.g., Re: your message to analyst).

The companion requires authentication to establish the tunnel and register with gee-webapp:

Terminal window
gee auth login

This opens a browser flow where you get a setup code, which gee-code exchanges for JWT tokens. Tokens are stored locally and auto-refresh.

The companion starts automatically in two contexts:

  1. With the daemongee-code daemon start launches both the daemon and companion
  2. With a REPL session — any authenticated gee-code session starts a companion

To disable the companion when starting the daemon:

Terminal window
GEE_CODE_COMPANION=0 gee-code daemon start

Check companion status:

Terminal window
gee-code daemon status

The status output shows whether the companion is running, the local port, and whether the tunnel is connected.