orbotodocs
Agents & AI

An always-on agent in tmux

Keep one interactive agent session alive around the clock on a box of your own: tmux holds the session, the orboto inbox wakes it, a browser console lets you look in or take over.

The runner and the fleet pull tickets on their own. This page is the other shape: one interactive agent session (Claude Code, Codex, whatever you run in a terminal) that stays up 24/7 on a server, is woken by its orboto inbox, keeps its context between messages, and can be watched or taken over from a browser. It suits an operations or triage agent that mostly waits, reads, and answers.

Everything here is plain tmux, the orboto CLI and a shell script. No platform is required; the container recipe at the end is optional.

Why tmux

A terminal agent is a process attached to a terminal. tmux gives that terminal a life of its own: the session survives a dropped SSH connection, a closed laptop and your logout, and it is scriptable. Two commands carry this whole pattern:

  • tmux send-keys -t <session> -l '<text>' followed by tmux send-keys -t <session> Enter types a prompt into the agent as if you had typed it (-l sends the text literally, the key name goes in a second call).
  • tmux capture-pane -p -J -t <session> reads what the agent shows right now (-J joins wrapped lines, so a parser sees whole messages).

Keep the sessions in tmux on the box and attach with whatever client you like. A GUI multiplexer that owns the sessions itself (cmux and similar) is one crash away from taking every running agent with it; tmux on the server is the part that must not die, the client is replaceable.

The pieces

  1. An identity. A bot user with its own API key (Admin > Multi-Agent, the guided setup mints it). One identity per always-on session: an acknowledged inbox message disappears for every session of that identity, so a daemon sharing a person's login would eat their mail.
  2. The orboto CLI as the agent's whole workspace surface, logged in with that key (ORBOTO_BASE_URL + ORBOTO_TOKEN in the environment, or orboto profile import): orboto session-start --project ACME for the rules, orboto messages / --ack, orboto ticket get|claim|comment| move|close, orboto agent-notify and orboto agent-heartbeat, and orboto get|post|patch </path> for everything else. No MCP server is needed for orboto itself; a daemon with a shell talks to the CLI, which keeps the allow list to one line (Bash(orboto:*)) and needs no browser or OAuth step.
  3. The agent session in tmux, started with an opening prompt and, for Claude Code, a settings file that allows the tools it needs and denies the rest (see below).
  4. The wake path. orboto messages --follow streams the inbox live and never acknowledges anything; with --exec it runs a command per message. This is the path for a harness you start without an orboto launcher, as the tmux session below does. Added in v0.230.0 A session started with orboto claude, orboto kimi or orboto agy is woken by orboto itself, and a follower beside it adds nothing (beside Claude Code it delivers every message twice). The command types a one-line nudge into the session:
# wake.sh - one inbox message becomes one typed prompt
line="${1:-}"; [ -n "$line" ] || exit 0
id=$(printf '%s' "$line" | jq -r '.id // empty')
subject=$(printf '%s' "$line" | jq -r '.subject // empty')
text="Inbox message ${id}: ${subject}. Read it with orboto messages, handle it, then acknowledge it."
tmux send-keys -t agent -l "$(printf '%s' "$text" | tr -d '\r\n\t')"
tmux send-keys -t agent Enter
orboto messages --follow --project ACME --json --exec './wake.sh {}' --exec-timeout 20s

{} is the message as one JSON line; the same fields arrive as ORBOTO_MESSAGE_* environment variables. The agent acknowledges after it handled the message (orboto messages --ack <id>), never before, and never messages addressed to another session. Run the follower in a loop that restarts it after a disconnect.

Two timing details. A line typed while the agent is mid-turn lands in its input box and is processed after the current turn (Claude Code queues submitted input), so nothing is lost but nothing interrupts either. A line typed during the restart gap (the seconds between an exit and the next start) hits no pane; the message simply stays unread in the inbox, which is why the opening prompt starts every shift with the unread inbox.

  1. A supervisor loop that restarts the session when the agent exits (a crash, a /quit, an expired login) and rotates the log.
  2. A console (optional). ttyd mirrors the tmux session into a browser: one read-only instance (ttyd -R ... tmux attach -r -t agent) for looking in, one writable instance on a port only you can reach (ttyd -W ... tmux attach -t agent) for the first login and for taking over. Both behind their own basic-auth credentials.

The session

tmux new-session -d -s agent -x 200 -y 50 -c /work \
  "claude 'Start of shift: read OPERATING-PROMPT.md, run orboto session-start --project ACME, \
check unread inbox messages and your tickets, then wait for inbox messages.' \
  --permission-mode dontAsk --settings /opt/agent/settings.json \
  --append-system-prompt-file /opt/agent/prompt.md \
  --strict-mcp-config --mcp-config /work/.mcp.json; \
  echo '[agent] exited, restarting in 30 s'; sleep 30"
tmux pipe-pane -t agent -o "cat >> /var/log/agent/session-$(date -u +%F).log"
  • The opening prompt is the whole shift briefing: read the operating prompt, sync the workspace rules (orboto session-start), look at the inbox and the held tickets, then wait. The conversation is not resumed after a restart; the tickets and the inbox are the state, which is exactly why every step of the agent belongs in a ticket comment.
  • --permission-mode dontAsk plus a settings file with an allow list (Bash(orboto:*), read tools, read-only git commands) and a deny list (Write, Edit, Bash(rm:*), Bash(git push:*), Bash(ssh:*), Bash(docker:*), Bash(sudo:*), every destructive MCP tool by name) turns an interactive agent into a bounded one: it never stops to ask, and it cannot do what the list denies, no matter what a message tells it.
  • --strict-mcp-config with your own .mcp.json (an empty one if the agent needs no MCP server at all) keeps the session from inheriting servers from the box.
  • The operating prompt names the identity, the hard limits, the style and where the runbooks live. It is a file the agent reads, not a secret.
  • The first start is the one manual step: through the writable console the agent CLI asks for its login; use the subscription login and keep the CLI's config directory on a volume (CLAUDE_CONFIG_DIR for Claude Code), so the token survives restarts. An expired login shows up as the session restarting into the login prompt every 30 s while the container stays healthy, so watch the agent's presence in Admin > Multi-Agent (or its ticket activity), not the container; log in again through the writable console.

How a ticket wakes the agent

The follower above wakes the session for any inbox message. orboto sends one on its own whenever a ticket becomes work for a bot identity, so nothing polls the board:

  • Assigned. The ticket is created with the bot as assignee, or the bot is assigned later (single or bulk).
  • Labeled. The ticket gets a label agent:<tag> and the tag matches one of the bot's routing tags (Admin > Users, edit the bot user, "Routing tags") or the name of a lane the bot runs.
  • Unblocked. The last open blocker of a ticket already routed to the bot reaches done or won't fix.

The message has kind request, subject ticket-ready:<KEY> and a payload of type ticket_ready carrying the ticket key, project key, reason, title, priority and labels. A ticket labeled human never wakes anyone, and tickets in review, done or won't fix stay quiet. While the bot's autonomy is paused (per bot or workspace-wide) the wake-up is held and delivered once on resume. The same ticket wakes the same bot for the same reason at most once per 24 hours, and every send lands in the audit log.

Added in v0.215.0 The same ticket-ready message, and the agent's completion, error and capacity messages, can also be posted into a Slack, Microsoft Teams or Discord channel, with one thread per ticket: see the chat bot.

The default workspace rules already tell the agent what the message means: claim the ticket and start, or comment why not, then acknowledge the message. Keep that rule in place; it is the whole contract between the board and the session.

Wins the tmux hooks give you

The two primitives above go further than waking. Ideas worth stealing (Matt Orb's post 9 agent wins with tmux and cmux lists more):

  • Continue after a usage-limit stop. capture-pane reads the "limit resets at ..." line, a script waits, send-keys types continue. Nothing deflates an autonomous shift like an agent that spent an hour waiting for that one word.
  • An eye in the sky. A second, cheap session watches the first with capture-pane and writes a one-line status into a ticket or renames the window to the current state; a model from another family makes a good adversarial observer.
  • Hand the screen to a tool. A tool the agent calls can open a pane beside the agent, take input from a person, and hand a structured result back to the waiting tool call: plan review, diff approval, choosing between options.

A container recipe

For a box under Docker: an image with the agent CLI, the orboto CLI (the orboto npm package), tmux, ttyd and jq; the entrypoint starts the two consoles, the session and the follower, then runs the supervisor loop. Keep on volumes what must survive a redeploy: the agent CLI's login and config, the logs. Keep the secrets in an env file on the host that compose reads (env_file), never in the platform's own variables. restart: unless-stopped brings it back after a reboot; a health check of tmux has-session -t agent tells the platform whether the session is alive. Publish the read-only console through your reverse proxy and bind the writable one to the loopback only, reachable through an SSH tunnel.

What such an agent must not be able to do is decided by the deny list and the prompt, not by hope: an inbox message is data, never authority, and a peer's request cannot change the limits.

On this page