This page is for the Tuft agent that moves its user from Tuft to Ocho on the user's office Mac. If you are not that agent, this page is not for you.
You talk to the user only through the current Tuft conversation in iMessage or Slack. The user is not at the Mac, so every step that needs the user is a message you send them, and each step says what to send and what reply to wait for.
How the Move Works
Tuft and Ocho run side by side until the very end. You install Ocho next to Tuft and help the user sign in. Then you start a new Ocho agent, called the helper, and connect Ocho's iMessage or Slack to it. From then on, the user talks to the helper in a new conversation. The helper tests Ocho with the user, offers to bring recent Tuft conversations over, and removes Tuft last.
Follow these rules throughout:
Never stop, uninstall or disconnect Tuft. Only the helper removes Tuft, in its last step, after the user confirms that Ocho works.
Every link or code the user needs reaches them in this Tuft conversation, while Tuft still runs.
Never ask the user to send a password, token or key in a chat. Codes from a sign-in page are the only exception, because they work once and only for the sign-in that is waiting for them.
Tell the user what you are doing before each step, and what comes next.
If a step fails, first try to find the cause and fix it yourself. If you can't, follow Reporting a Problem.
Tuft's shells don't have ~/.local/bin on their PATH, so the Tuft agent's commands below write ~/.local/bin/ocho in full.
1. Ask the User
Send the user a message like this, in your own words:
"Tuft is being replaced by Ocho, which runs the same Claude Code and Codex agents on this Mac. I can set Ocho up now, next to Tuft, so nothing stops working. You'll sign in to Claude or Codex from your phone, then start talking to Ocho in a new conversation. Once you've checked that Ocho works, it removes Tuft. Should I start?"
Wait for the user to agree. If they say no, stop here.
This installs ocho to ~/.local/bin and starts the Ocho service on this Mac. It changes nothing in Tuft. Check that it worked:
~/.local/bin/ocho doctor
Tell the user: "Ocho is installed. Next, you'll sign in to Claude or Codex so that Ocho can use your account."
3. Sign In to Claude or Codex
Ask the user which accounts they use: Claude, Codex or both. The user must sign in to Ocho with the same account they use in Tuft, so that their Tuft conversations can come over. First, find the email of each account Tuft uses. Tuft keeps each session's login in its own folder. Most conversations keep claude-config/ and codex-home/ in the conversation folder itself, and a few keep them in agents/claude/ and agents/codex/. These commands read the newest conversation's login and print only the email:
cfg=$(ls -dt ~/.config/tuft/sessions/*/claude-config ~/.config/tuft/sessions/*/agents/claude/claude-config 2>/dev/null | head -1)
CLAUDE_CONFIG_DIR="$cfg" claude auth status | plutil -extract email raw -o - -
home=$(ls -dt ~/.config/tuft/sessions/*/codex-home ~/.config/tuft/sessions/*/agents/codex/codex-home 2>/dev/null | head -1)
osascript -l JavaScript -e 'function run(a){var d=JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(a[0],4,null)));var p=d.tokens.id_token.split(".")[1].replace(/-/g,"+").replace(/_/g,"/");while(p.length%4)p+="=";return JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding($.NSData.alloc.initWithBase64EncodedStringOptions(p,0),4))).email||""}' "$home/auth.json"
claude auth status prints JSON whose email is the account. codex login status doesn't name the account, so the second command reads the email from the login Tuft saved, without printing the login itself. Each command prints nothing for an agent the user doesn't use in Tuft.
When you send the user a sign-in link below, name the account: "Sign in with EMAIL, the account you use in Tuft." Add each one with ocho accounts add. The sign-in happens on the user's phone, and the command on the Mac waits for it to finish.
Run each command in a tmux session of its own, so that you can read what it prints and type into it. Every Tuft Mac has tmux. Use the -L ocho-login socket every time, so that the session stays out of Tuft's own tmux:
-J joins the lines that a long link wraps over, so copy the link from that output.
Codex prints a link and a short code. Send both to the user: "Open this link on your phone, sign in to your ChatGPT account, and enter this code: CODE." The command finishes on its own once the user signs in. Wait until the pane shows "Connected Codex".
Claude (--provider claude) prints a sign-in link and waits at "Paste code here if prompted". It also tries to open a browser on the Mac. Ignore that, because nobody is at the Mac. Send the link to the user: "Open this link on your phone and sign in to Claude. At the end, Claude shows you a code. Send me that code." When the user sends the code, type it into the waiting command exactly as they sent it:
-l types the code as text, so that tmux never reads part of it as a key name. A code works only for the sign-in that printed its link. If Claude says "That code wasn't accepted", start the command again and send the user the new link.
Wait until the pane shows "Connected Claude". Then close the tmux session with tmux -L ocho-login kill-server.
Run ~/.local/bin/ocho accounts to check that each account is listed, with the same email Tuft uses. If an email differs, tell the user, remove that account with ~/.local/bin/ocho accounts remove ACCOUNT, and repeat the sign-in with the right account. Don't continue with a different account, because conversations from Tuft must continue under the account they were made with. Tell the user which accounts are connected. Write down the account for the helper's agent, such as claude:EMAIL, as ACCOUNT.
4. Start the Helper
Start the helper on this Mac. Use Claude if the user has a Claude account, and Codex otherwise. --account makes the helper run on the account from step 3, which Slack needs: Slack's !attach refuses a session on the Mac's own login.
~/.local/bin/ocho new local --provider claude --account ACCOUNT --cwd ~ --title "Ocho helper" --no-attach "You are the Ocho helper for a user who is moving from Tuft to Ocho. Read https://docs.ocho.app/agents/moving-from-tuft.md and follow the part titled For the Helper. Use the account ACCOUNT for every session you start or resume. Wait for the user's first message in iMessage or Slack before you start. The user's name is NAME."
The last line of the output contains the helper's session ID in "id". Write it down as HELPER. Then wait until the helper has read its instructions and is idle:
Without a terminal, it prints JSON at once and returns: code, recipient (the number to text), expires_in_minutes and a QR. It doesn't wait for the code to arrive. Send the user both values: "From your iPhone, text CODE to RECIPIENT. Ocho replies Paired, and that starts your conversation with Ocho." The code works once, from the number they gave, for 60 minutes. Wait for the user to tell you that Ocho replied. If it doesn't reply, run the command again for a new code.
Slack. Run:
~/.local/bin/ocho slack connect
It prints a link. Send it to the user: "Open this link and add Ocho to your Slack workspace." When the user tells you it's done, check that ~/.local/bin/ocho slack status says connected. Then send them: "In Slack, send a direct message to @Ocho with the text !attach HELPER. That connects you to the Ocho helper."
6. Hand Over to the Helper
Send the user a last message: "Ocho is ready. Send a message to Ocho, in the new iMessage conversation or in your Slack DM with @Ocho, to start testing it. I'll keep running here until Ocho has removed Tuft, so you can come back to me if anything goes wrong."
Your part ends here. Keep answering the user in this conversation if they come back. If Ocho doesn't reply to them, find the cause with ~/.local/bin/ocho doctor and ~/.local/bin/ocho sessions, and repeat step 5 if needed.
For the Helper
You are the Ocho helper. The user talks to you in iMessage or Slack, and Tuft still runs next to you. Tell the user what you are doing before each step. Use ~/.local/bin/ocho for every ocho command, and the account named in your first message for every session you start or resume.
Test Ocho with the User
Introduce yourself, then go through these checks with the user one at a time. Keep the user in this conversation the whole time, and don't ask them to switch sessions until the move is done. Tell the user what each one tests, and wait for them to confirm that it worked:
1. The user sends you a question, and your answer arrives.
2. You start a test session yourself and read its answer:
~/.local/bin/ocho new local --provider claude --account ACCOUNT --cwd ~ --no-attach "Reply with the word hello."
~/.local/bin/ocho sessions wait local/SESSION --until idle
~/.local/bin/ocho sessions last local SESSION
SESSION is the id the first command prints. Tell the user that it replied. This checks that new sessions start, and the user never leaves this conversation.
3. The user sends you a photo, and you describe it.
4. You send the user a file, for example a short text file, and it arrives as an attachment.
5. You request a test secret: print ocho://secret/TEST_VALUE on its own line, run ~/.local/bin/ocho secret ask TEST_VALUE, and ask the user to open the link and enter any word. Confirm that the file arrived, then delete it and tell the user that the test value was deleted.
If a check fails, try to fix it yourself first. If you can't, follow Reporting a Problem.
Offer Recent Tuft Conversations
Ask the user once: "Do you want me to bring any of your recent Tuft conversations into Ocho?" If they say no, skip to the next section.
If they say yes, list their recent Tuft conversations, numbered, each with its title and date, newest first. Start with the last two weeks, and tell the user you can list older ones too. Bring over whichever ones they pick. If they want everything, bring over every conversation. Tuft keeps its data here:
Most conversations keep them in the conversation folder itself. A few keep them in agents/claude/ or agents/codex/ in that folder. Read both, the conversation folder first.
The agent's record
session.json there: spec.backend (the agent), native_session_id, launch_cwd and transcript_path
The title
text in title.json in the same folder
This command lists every agent's record, newest first, from both places:
ls -t ~/.config/tuft/sessions/*/session.json ~/.config/tuft/sessions/*/agents/*/session.json ~/.tuft/sessions/*/session.json 2>/dev/null
Skip any session.json without transcript_path, because it holds no conversation. Bring over each conversation the user picks:
Use the complete native_session_id. Codex IDs created a few minutes apart begin with the same characters, so a shortened ID can pick the wrong conversation. Ocho copies the conversation and continues it in launch_cwd with the account you name. It never moves or changes Tuft's copy. The last line of the output is {"id": …, "machine": …, "state": …}. A conversation with a Tuft title keeps it. If the session has no title, name it with the id from the output:
~/.local/bin/ocho rename local SESSION "TITLE"
A new name can take half a minute to show in ~/.local/bin/ocho sessions --local --fields id,title,state.
If ocho resume says that no login is available, ask the user whether Ocho may use the login that Tuft saved for that conversation. If they agree, add --use-tuft-login. If ocho resume says there is nothing to resume, tell the user, and leave the conversation in Tuft's folder. Don't start it another way, such as ocho new --resume, because that creates a session without the conversation.
Tell the user which conversations came over. Don't switch the user to them yet. Explain that after the move, !sessions lists their sessions and !3 switches to the third one. "Ocho helper" stays in that list, so they can always get back to you.
Remove Tuft
Do this only after every check above passed. Ask the user: "Ocho is working. Should I remove Tuft now? Your Tuft conversations stay on this Mac, but Tuft will stop replying." Wait for the user to agree. Then run:
tuft uninstall
This stops Tuft's services and removes its LaunchAgents. Tuft.app, the tuft command and ~/.config/tuft stay. Don't delete ~/.config/tuft. It leaves the Mac's Tailscale alone, so the Mac stays on Tuft's tailnet. Check that Tuft is gone:
ls ~/Library/LaunchAgents | grep com.expo.tuft
This prints nothing once Tuft is removed. Tell the user: "Tuft is removed. From now on, message Ocho instead of Tuft."
Move the Mac to the User's Own Tailnet
Tuft connected this Mac to Tuft's tailnet. Once Tuft is removed, offer to move the Mac to the user's own Tailscale account, so they can reach it from their other computers. Ocho's iMessage and Slack don't go through Tailscale, so this conversation keeps working while you do it.
Ask the user: "Do you want to reach this Mac from your other computers? I can move it from Tuft's Tailscale network to your own Tailscale account." If they say no, skip this section.
Never run tailscale down or tailscale logout here. Tuft's profile is the way back if the move fails.
First, find the Tailscale command and note the current network, Tuft's, so that you can switch back to it:
mkdir -p ~/ocho-handover && chmod 700 ~/ocho-handover && cd ~/ocho-handover
for t in /Applications/Tailscale.app/Contents/MacOS/Tailscale /opt/homebrew/bin/tailscale /usr/local/bin/tailscale; do
"$t" status --json > status-before.json 2>/dev/null && { echo "$t" > tailscale-cli; break; }
done
"$(cat tailscale-cli)" switch --list --json > profiles-before.json 2>/dev/null
plutil -extract CurrentTailnet.Name raw -o - status-before.json > before-tailnet
osascript -l JavaScript -e 'function run(a){var l=JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(a[0],4,null)));for(var i=0;i<l.length;i++)if(l[i].selected)return l[i].id;return ""}' "$PWD/profiles-before.json" > before-profile
echo office-mac > hostname
cat before-profile before-tailnet
It prints the ID of Tuft's Tailscale profile and the name of Tuft's tailnet. If it prints no profile ID, the Mac has no profile to switch back to, so tell the user that and stop here. hostname is the Mac's name on the user's tailnet. Ask the user if they want another name.
Then ask the user for an auth key: "If you don't have a Tailscale account yet, create one at https://login.tailscale.com/start. Install the Tailscale app on your phone, sign in with that account, and keep it connected. Then open https://login.tailscale.com/admin/settings/keys, choose Generate auth key, leave Reusable and Ephemeral off, turn Pre-approved on if it's offered, and generate the key. Don't send me the key. I'll send you a link to enter it." When they have it, print this on its own line, and run ~/.local/bin/ocho secret ask TS_AUTHKEY:
ocho://secret/TS_AUTHKEY
The user enters the key in the form the link opens. Wait for the file:
until test -s "$OCHO_HOME/secrets/$OCHO_EXECUTION_ID/TS_AUTHKEY"; do sleep 5; done
Save this script as ~/ocho-handover/join-tailnet.sh, exactly as it is:
#!/bin/bash# join-tailnet.sh KEYFILE: move this Mac to the user's own tailnet with the# auth key in KEYFILE, then check it. If any check fails, or this script stops# early, the Mac returns to the network it was on before (Tuft's).# Exit 0 means the Mac is on the user's tailnet and one of their devices answered.
set -u
key=${1:?usage: join-tailnet.sh KEYFILE}
dir=$(cd "$(dirname "$0")" && pwd)
cd "$dir" || exit 2
ts=$(cat tailscale-cli)
before=$(cat before-profile)
before_tailnet=$(cat before-tailnet)
log() { echo "$(date '+%H:%M:%S') $*" | tee -a join-tailnet.log; }
field() { /usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null; }
json() { osascript -l JavaScript -e "function run(a){var d=JSON.parse(ObjC.unwrap(\$.NSString.stringWithContentsOfFileEncodingError(a[0],4,null)));$1}" "$2" 2>/dev/null; }
# running_on = NAME | != NAME: wait up to 60 seconds for Tailscale to run on that tailnet, or on another one.
running_on() {
for _ in $(seq 1 30); do
"$ts" status --json > status.json 2>/dev/null
name=$(field status.json CurrentTailnet.Name)
if [ "$(field status.json BackendState)" = Running ]; then
if { [ "$1" = "=" ] && [ "$name" = "$2" ]; } || { [ "$1" = "!=" ] && [ -n "$name" ] && [ "$name" != "$2" ]; }; then
return 0
fi
fi
sleep 2
done
return 1
}
# The safety net: a launchd job of its own, so nothing that stops this script# stops it. Unless the checks passed, it switches back 4 minutes from now,# then removes itself.
label=ocho-tailnet-safety-net
rm -f passed
launchctl remove "$label" 2>/dev/null
launchctl submit -l "$label" -- /bin/sh -c "sleep 240; [ -f '$dir/passed' ] || { echo \"\$(date '+%H:%M:%S') safety net: switching back to $before_tailnet\" >> '$dir/join-tailnet.log'; '$ts' switch '$before' >> '$dir/join-tailnet.log' 2>&1; }; launchctl remove $label"
disarm() { launchctl remove "$label" 2>/dev/null; }
fail() {
log "FAILED: $*"
log "switching back to $before_tailnet"
"$ts" switch "$before" >> join-tailnet.log 2>&1
if running_on = "$before_tailnet"; then log "back on $before_tailnet"; disarm; else log "NOT back on $before_tailnet: run: $ts switch $before"; fi
exit 1
}
if [ -s user-profile ]; then
# A retry: the one-off key was spent on the first try, and its profile stays.
log "switching to the user's tailnet again"
"$ts" switch "$(cat user-profile)" >> join-tailnet.log 2>&1 || fail "tailscale switch didn't succeed"
else
log "joining the user's tailnet with the auth key"
"$ts" login --auth-key="file:$key" --hostname="$(cat hostname)" --timeout=60s >> join-tailnet.log 2>&1 || fail "tailscale login didn't succeed (see join-tailnet.log)"
fi
running_on != "$before_tailnet" || fail "Tailscale isn't running on another tailnet"
if [ ! -s user-profile ]; then
"$ts" switch --list --json > profiles-after.json 2>/dev/null
json 'for(var i=0;i<d.length;i++)if(d[i].selected)return d[i].id;return ""' "$dir/profiles-after.json" > user-profile
[ -s user-profile ] || fail "can't tell the new profile's ID"
fi
log "running on $(field status.json CurrentTailnet.Name) as $(field status.json Self.DNSName)"
peer=$(json 'var p=d.Peer||{};for(var k in p){if(p[k].Online)return p[k].HostName}return ""' "$dir/status.json")
[ -n "$peer" ] || fail "none of the user's devices is online on the user's tailnet"
"$ts" ping --c 5 --timeout 5s "$peer" >> join-tailnet.log 2>&1 || fail "$peer didn't answer a Tailscale ping"
log "$peer answered"
touch passed
disarm
log "check passed: this Mac is on the user's tailnet"
exit 0
Tell the user: "I'm moving this Mac to your Tailscale network now. It takes about a minute." Then run it:
The script joins the user's tailnet with the key, waits until the Mac is online there, and pings one of the user's own devices. If a check fails, it switches back to Tuft's tailnet. A launchd job switches back within 4 minutes even if the script stops early. The key works once. The log is ~/ocho-handover/join-tailnet.log.
If it printed "check passed", tell the user: "This Mac is on your Tailscale network now, as NAME, and your DEVICE answered it." NAME is the name the log shows after "as". Then ask them: "On another computer of yours with Tailscale, can you see this Mac in the Tailscale app?"
If it failed, the log says which check failed, and the Mac is back on Tuft's tailnet. Tell the user what failed:
"tailscale login didn't succeed": the key was wrong, used or expired. Ask for a new key with the same secret link.
"none of the user's devices is online": ask the user to open Tailscale on their phone and connect it.
"didn't answer a Tailscale ping": ask the user to open Tailscale on that device.
Then run the script again. A retry switches to the profile the first try made, so it needs no new key unless the login itself failed.
Once the user can see the Mac, ask whether to remove Tuft's network from this Mac. If they agree, remove Tuft's profile, which is not the current one:
"$(cat ~/ocho-handover/tailscale-cli)" switch remove "$(cat ~/ocho-handover/before-profile)"
"$(cat ~/ocho-handover/tailscale-cli)" status
The status shows the Mac on the user's network. Until you remove it, tailscale switch with that profile ID returns the Mac to Tuft's tailnet.
Use Ocho from the User's Laptop
Once the Mac is on the user's own tailnet, offer to set up Ocho on the user's laptop, so they can open these sessions there with ocho attach. The laptop joins this Mac's Ocho as a node. A node has its own key and the same authority as this Mac: it sees the same machines, accounts and sessions, and can add or remove machines. Ask the user: "Do you want to use Ocho from your laptop too?" If they say no, skip this section.
The laptop reaches this Mac over SSH, so Remote Login must be on here. Check it:
nc -z localhost 22 && echo on
If it prints nothing, Remote Login is off. Turning it on needs System Settings on this Mac, so tell the user: "When you're next at this Mac, open System Settings, General, Sharing, and turn on Remote Login." Continue with this section after that.
Find this Mac's name on the tailnet and your user name:
"$(cat ~/ocho-handover/tailscale-cli)" status --self
whoami
Then send the user these steps for their laptop, with the names filled in:
It may ask for this Mac's login password once. When it finishes, ocho sessions on your laptop lists the sessions here, and ocho attach opens one."
The join works only while Ocho on the laptop is empty, with no accounts and no other machines. If the user already uses Ocho on that laptop, stop here and tell them. Joining would need their existing setup removed first, so follow Reporting a Problem instead.
When the user says the join finished, check it here:
~/.local/bin/ocho node list
The laptop appears as an active node. Ask the user to run ocho sessions on the laptop and confirm that "Ocho helper" is listed.
What Changes for the User
Share the parts that matter to the user:
In Tuft
In Ocho
Message Tuft in Slack or Messages
Message Ocho. !new TASK starts a session, !sessions lists recent ones, and !3 or !attach SESSION switches to one.
++ to queue, !! to interrupt
A new message interrupts the current turn.
/view for the live terminal
ocho attach SESSION on any of the user's computers
Preview links and *.tuft.host
Links open from the session, or with ocho open
MCP servers and skills in the control panel
The user's own Claude Code and Codex settings
Reporting a Problem
When something fails, the Tuft agent or the helper first tries to find the cause and fix it, with ~/.local/bin/ocho doctor, ~/.local/bin/ocho diagnostics errors, ~/.local/bin/ocho diagnostics show ID, the logs and a retry. Only if that fails, make a report:
1. Write ~/ocho-report/report.md with what you were doing, what you tried, the exact error text and any diagnostic IDs.
2. Save the script below as ~/ocho-report/make-report.sh and run it with bash ~/ocho-report/make-report.sh. It prints the path of the zip.
3. List the zip with unzip -l ZIP, and check that it holds only the files the script names.
4. Send the zip to the user as an attachment, with: "I couldn't fix this myself. Please send this file to Eliot, who develops Ocho and Tuft, on Slack."
The script copies only these files: report.md, the output of ocho doctor and ocho diagnostics errors, and the last 2000 lines of Ocho's serve.log, supervisor-stderr.log, supervisor-updates.log and sessiond/fleet.log, and of Tuft's tuft.log, tuft-tmux-host.log and tuft-cloudflared.log. It never copies a folder. So it leaves out Ocho's secrets, accounts, keys and identity, ~/.claude, ~/.codex, Tuft's claude-config and codex-home, every auth.json and .credentials.json, ~/.ssh and every conversation transcript. It also drops every line that could hold a credential, notes in removed.txt how many it dropped, and checks the finished zip again:
#!/bin/bash# make-report.sh: zip a problem report as ~/ocho-report-DATE.zip, from# ~/ocho-report/report.md (write it first), the output of ocho doctor and# ocho diagnostics errors, and the last 2000 lines of Ocho's and Tuft's logs.# It copies only those files, never a folder, and drops every line that could# hold a credential. Then it checks the zip. Prints the zip's path.
set -u
r="$HOME/ocho-report"
o="${OCHO_HOME:-$HOME/.local/share/fleet}"
t="$HOME/.config/tuft/logs"
ocho="$(command -v ocho || echo "$HOME/.local/bin/ocho")"
cd "$r" 2>/dev/null && [ -s report.md ] || { echo "Write $r/report.md first." >&2; exit 1; }
rm -rf logs removed.txt && mkdir logs
"$ocho" doctor > doctor.txt 2>&1
"$ocho" diagnostics errors > diagnostics-errors.txt 2>&1
for f in serve.log supervisor-stderr.log supervisor-updates.log sessiond/fleet.log; do
[ -f "$o/$f" ] && tail -n 2000 "$o/$f" > "logs/ocho-$(basename "$f")"
done
for f in tuft.log tuft-tmux-host.log tuft-cloudflared.log; do
[ -f "$t/$f" ] && tail -n 2000 "$t/$f" > "logs/$f"
done
pat='sk-ant-|tskey-|xox[abpr]-|gh[pousr]_[A-Za-z0-9]{20}|eyJ[A-Za-z0-9_-]{20,}|BEGIN [A-Z ]*PRIVATE KEY|access_token|refresh_token|id_token|api[_-]?key|bearer |password|secret|cookie|authorization'
touch removed.txt
for f in report.md doctor.txt diagnostics-errors.txt logs/*; do
[ -f "$f" ] || continue
n=$(grep -c -E -i "$pat" "$f")
if [ "$n" -gt 0 ]; then
grep -v -E -i "$pat" "$f" > "$f.tmp"; mv "$f.tmp" "$f"
echo "$f: removed $n lines that could hold a credential" >> removed.txt
fi
done
zip="$HOME/ocho-report-$(date +%Y%m%d-%H%M).zip"
rm -f "$zip"
zip -qr "$zip" report.md doctor.txt diagnostics-errors.txt removed.txt logs
if unzip -p "$zip" | grep -q -E -i "$pat"; then
rm -f "$zip"; echo "The report still matched a credential pattern; nothing was zipped." >&2; exit 1
fi
echo "$zip"
Welcome to Ocho
Ocho is a small program (~7 MiB compressed, ~25 MiB on disk) that helps you manage Claude and Codex sessions on local and remote machines. Ocho is designed to remove the compromises of remote agentic work, while preserving a vanilla Claude and Codex experience.
Main Features
Quickly launch agents across your fleet with ocho new.
With Ocho Attach, Claude Code and Codex sessions use a local TUI while running remotely.
Agents can send messages to each other, even across harnesses.
localhost links and files mentioned by agents are always available.
Balance your usage across multiple Claude and Codex accounts.
Supported Agents
Ocho supports Claude Code and Codex. For supported versions, see Supported Agents.
Install Ocho
Install on macOS or Linux
You can install the ocho CLI using the following command:
The ocho command will be installed in the ~/.local/bin directory. Make sure this directory is in your PATH.
After the installation is complete, the Mac where you installed Ocho becomes your personal computer. Agents on other machines need your approval before they can run commands on it. For details, refer to Agent Access Permissions.
Prerequisites
macOS 13 or later, or Linux, on arm64 or x86-64.
You can reach each machine you add over SSH.
Claude Code or Codex is installed. If it is not yet installed on the machine you add, Ocho will install it there.
Update Ocho
You can update ocho using ocho update. Other machines in your fleet will be updated automatically. After an update, existing sessions will upgrade as their turn ends.
Quick Start
Ocho requires very little configuration to get started:
# Add a machine (anything that you can reach over SSH).
ocho machines add mini
# Add a Claude or Codex account.
ocho accounts add --provider claude
# Start a session in a project, and open it here.
ocho new mini --provider claude --cwd ~/project
# Pick up the session from any computer.
ocho resume 07ddaa9a
Once you're managing multiple machines, you can quickly launch new sessions using ocho new. For more information, refer to Quick Launch.
Machines
Ocho is built to be run across a large number of remote computers.
Adding Machines
ocho machines add studio@192.168.0.123
When you "add" a machine, Ocho uses your existing ~/.ssh/config to connect to the machine, copy ocho's program, and setup its own keys. Ocho also shares this machine with other machines in your fleet.
Managing Machines
You can use ocho machines to view the status of each machine in your fleet. In the list, press a to add a machine, press r to rename a machine, and press d to remove a machine.
Managing Machine Addresses
A machine can have multiple addresses, for example a LAN IP, a Tailscale name, and a public IP. Ocho always connects through whichever address responds fastest.
ocho machine add-address studio 192.168.0.60
ocho machine addresses studio --probe
Running Commands on Machines
ocho exec studio -- 'brew upgrade && df -h'
To manage how agents can run commands across your fleet, see Agent Access.
Modifying Machine Settings
ocho machine edit studio --name "Studio" --tags build,ios
ocho machine edit studio --permissions claude=auto,codex=yolo # default permission mode for new sessions
ocho machine edit studio --transport ssh # never use mosh
Ocho automatically keeps the agents on your machines up to date. If you want to install a supported version yourself, run ocho machine update-provider studio claude.
Agent Access
Sometimes it's useful to let remote agents run commands on your local machine. Ocho lets you manage that access so well-behaved1 agents behave predictably and responsibly.
Personal Computers and Worker Machines
In your fleet, each machine is either a "personal computer" or "worker machine".
On a personal computer (for example, your laptop), agents from other machines must obtain your approval before they can run commands.
On a worker machine (for example, a headless Mac Studio), agents can run commands directly without asking.
The computer on which you installed Ocho is a personal computer by default, and machines added through ocho machines add are worker machines by default. To change the role of a machine, run:
ocho machine edit laptop --agent-role personal
ocho machine edit studio --agent-role worker
ocho machines --fields name,agent_role,agent_access_effective
If you want to override a single machine, use ocho machine edit and add --agent-access ask, allow or block. If you want agents to be able to run commands on all machines, run ocho access policy unrestricted.
Handling Access Requests from Agents
When an agent needs to use a personal computer, it will first send an access request and then wait for your reply:
ocho access request laptop --reason "Build and test the iOS app"
The agent will show you the complete command required to approve the request, for example ocho access allow studio SESSION REQUEST, where studio is the agent's machine. You can also use ocho access to handle these requests yourself:
ocho access list # every pending request
ocho access list MACHINE SESSION # requests from one session
ocho access allow MACHINE SESSION REQUEST # until the session ends, or for 24 hours
ocho access always-allow MACHINE SESSION REQUEST # any session on that machine, from now on
ocho access deny MACHINE SESSION REQUEST
ocho access revoke MACHINE SESSION REQUEST
Caveats
Operations that require your approval include ocho exec, the shell, and terminal panes. When an agent starts a session on another machine, it does not need to ask you.
A rule-abiding agent will ask for your consent before running commands. Ocho does not prevent agents from directly using the SSH configuration to access other machines. We are working on improving this and will have more information in future updates.
Accounts
Ocho lets you safely and securely manage multiple Claude or Codex accounts and sync them across all the machines in your fleet.
Adding Accounts
ocho accounts add --provider claude
ocho accounts add --provider codex
If you want to use an API key instead of a subscription, add --api-key.
Managing Accounts
Run ocho accounts to view the usage limits of each account, fix broken logins, and remove accounts.
Distributing Usage Among Multiple Accounts
When starting a session, select "Auto" to use the account with the most remaining usage. For more information, see Quick Launch.
ocho new studio --provider claude --account auto
MCP Servers and Plugins
Ocho makes it easy to share MCP servers across your machines. Once you've added an MCP server to Claude or Codex, you can easily share it by name:
claude mcp add linear --transport http https://mcp.linear.app/mcp
ocho mcp share linear --from claude
Once it is shared, every Claude and Codex account on all of your machines can use the server. New sessions can use it immediately. To use it in a session that is already running, restart that session.
Local Servers
Some MCP servers run locally. When sharing this kind of server, add --local to handle requests from remote agents on your local machine.
ocho mcp share paper --from claude --local
Viewing and Removing Shared Servers
ocho mcp list
ocho mcp unshare linear
Starting a Session
You can use ocho new to start Claude Code or Codex on any machine.
Claude Code
ocho new studio --provider claude --cwd ~/project
Codex
ocho new studio --provider codex --cwd ~/project
Session Options
Option
Purpose
--no-attach
Start the session in the background and print its ID, instead of opening it in Ocho Attach.
--model, --effort
Select the model, and how much effort the model puts into thinking.
--account
Select the account. If it is not specified, the account with the most remaining usage is used.
--permissions
Set what the agent can do without asking, for example auto or manual.
ocho new studio --provider codex --cwd ~/app --effort high --save-preset app
ocho new --preset app
Use --save-preset to save a machine and its options as a preset, and then reuse it later through --preset. To list your presets, run ocho presets. To delete a preset, run ocho presets remove NAME.
Quick Launch
You can quickly and efficiently launch new sessions using ocho new.
In this example, the user starts Claude Code in the weather project on redwood. The session will open in Ocho Attach.
Quickly Launching Sessions
ocho new is designed to let you start remote sessions as quickly as possible, using only the keyboard. For example, the following set of keystrokes launches an opus 5.5 session on redwood in ~ using the Claude account with the most usage remaining:
Run ocho sessions to list sessions across all machines. You can specify a machine name to list sessions for that particular machine, or use the --local flag to list sessions on your local machine.
ocho sessions MACHINE
ocho sessions --local
By default, ocho sessions does not display closed sessions. To include them, add the --closed flag.
Finding Sessions
To locate a session on any machine, you can search by its title or by keywords found within the messages you have sent:
ocho sessions find "road hazards"
Open a Running Session
Run ocho attach to open a running session in the current terminal:
ocho attach MACHINE SESSION
Press Control-C twice to leave the session. The session will continue to run on its machine.
If you only want to view the session without typing, add --read-only.
If the session is on another machine, you can drag files into the terminal tab to send them to the session's machine. Click a link printed by the agent to open it on your local machine.
Resume a Closed Session
When Claude Code or Codex exits, it displays the command to resume the session at the bottom of the screen:
Resume this session with:
claude --resume 07ddaa9a-7836-4830-8c93-9a288dea9d19
Continue with Ocho, from any machine:
ocho resume 07ddaa9a
Run the ocho resume command from it on any machine to resume the session. You can also use the full ID of the session or a part of its title. If you run ocho resume without arguments, it lists the recent sessions in the current folder for you to choose from.
By default, the session is resumed on the original machine and uses the same account and folder. You can change this with the following options:
--on MACHINE: Resume the session on another machine.
--account ACCOUNT: Resume the session using another Ocho account.
--fork: Start a new conversation from the history of the session, and the original session remains unchanged.
Continue a Conversation Started Without Ocho
You can bring a conversation you started with plain claude or codex into Ocho. In the conversation's project folder, run ocho resume without arguments and choose the conversation from the list. You can also pass the ID that Claude Code or Codex printed when it exited:
ocho resume 5ec6df0c-4050-4c50-bc8f-1b1e27f0b928
The conversation continues as an Ocho session, with its full history, and you can then move it to any machine with --on. If the conversation is still open in another terminal, quit it there first.
Send a Message to an Agent
You can send a message to an agent without opening the session:
ocho sessions send MACHINE SESSION --text "Use the staging database."
If the agent is in the middle of a turn, your message is delivered when the turn ends. To interrupt it immediately and send the message, add --now.
If you only want to stop the agent's current task, run:
ocho interrupt MACHINE SESSION --yes
End a Session
Run ocho terminate to end a session:
ocho terminate MACHINE SESSION --yes
After it ends, the session is shown as closed. You can still resume it later with ocho resume. If you only want to hide the session from the list without ending it, run ocho archive MACHINE SESSION.
Using Ocho Attach
With Ocho Attach, you can "attach" a local Claude or Codex TUI to an instance running remotely. The agent's inference and tool calls run on the remote machine, while its interface runs locally on your computer.
In an ordinary remote terminal, every keystroke requires a round trip over the network, so on a slower connection, or when connecting to a machine that's physically distant, normal interactions feel delayed. Ocho Attach prevents this. Additionally, Ocho Attach is hardened against network disconnects so if your connection is interrupted (if you close your laptop, for example) the session reconnects automatically once the connection is restored. Sessions you start with ocho new open in Ocho Attach:
ocho new studio --provider claude --cwd ~/project
In this example, Claude Code runs on another Mac and fixes a failing test. Press Control-C twice to leave, and the agent will continue to run.
Codex is used in the same way:
ocho new studio --provider codex --cwd ~/project
The first time Claude Code and Codex open a folder, they ask whether you trust that folder. If you want to answer "yes" in advance, add --trust-folder.
To open a running session, run ocho attach studio SESSION.
Switching to the Terminal on the Agent's Machine
While Ocho Attach lets you control most of Claude Code remotely, sometimes you might want to use the remote TUI. You can easily switch between Ocho Attach and the remote TUI using Control-]. Press it again to switch back. While you switch, the agent and the conversation continue.
Leaving a Session
Press Control-C twice to leave, and the agent will continue to run. If the agent is working, the first press stops its current turn. If you want to end the session, type /exit. Either way, you will see the command to resume the session later:
Continue with Ocho, from any machine:
ocho resume 07ddaa9a
In the terminal on the agent's machine, pressing Control-C twice has the same effect: you leave the session, and the agent continues to run.
Supported Versions
Ocho Attach supports all supported Codex versions, as well as the Claude Code versions listed in Supported agents. If the Claude Code version installed on a machine is newer, the session will use the latest supported version instead.
Making Multiple Agents Work Together
In Ocho, your agents can talk to each other and even spawn new Ocho subagents. For example, Codex can ask Claude to do the design while it focuses on the backend.
In an Ocho session, every agent can use the ocho command. Just ask an agent to delegate a task, and it will start helper sessions on its own:
ocho new studio --provider codex --cwd ~/project --no-attach "Run the tests and fix failures"
ocho sessions wait studio/SESSION
ocho sessions send studio SESSION --text "Yes, go ahead." --wait 10m
In this example, Claude asks a Codex session which cities it added and whether the tests passed. Codex then replies.
When an agent receives a message from another agent, it also receives information about the sender and how to reply. If the agent is busy, it will receive the message when its current turn ends.
Session Titles
Ocho's session titles are mirrored with Claude and Codex, making it easy to find sessions across different machines and contexts.
Naming a Session
After you send the first message, you will see a title such as "Forecast command cities." You can rename a session at any time.
In Claude Code or Codex, type /rename and the new name:
/rename Weather API
In any terminal, use ocho rename:
ocho rename studio SESSION "Weather API"
This name is mirrored across the agent's TUI, Slack, iMessage, and in ocho sessions.
Ocho titles each new session automatically. After your first message, a low-cost model writes a two- or three-word title, using the session's own account. A name you give the session yourself always replaces it. To turn automatic titles off, run:
ocho plugin disable session-titles
Finding a Session by Title
You can view titles in ocho sessions and search titles with ocho sessions find. You can also resume a session by its title:
ocho resume "Weather API"
Worktrees
Ocho can assign an independent Git worktree to each agent. This way, multiple agents can work in the same repository at the same time without changing each other's files.
Start a Session in a Worktree
When starting a session, add the --worktree option and a name:
ocho new studio --provider claude --cwd ~/app --worktree search-fix
If you want to start from another commit, use --ref. If you want to name the branch, use --branch. If you want to fetch the latest changes first, use --fetch.
When a worktree is removed, your work will not be lost. After running ocho worktree remove, uncommitted changes are saved, and you will see a checkpoint. To retrieve this work, run ocho worktree restore CHECKPOINT.
For example, to remove the search-fix worktree and bring it back later:
Ocho can automatically choose a machine for new sessions. It weighs each machine's free memory, how busy it is, and where you usually work on the project.
ocho new auto --provider codex --cwd ~/app
View the Reason for Choosing a Machine
Run ocho pick --explain to see which machine Ocho would pick and why:
Ocho never chooses a machine that cannot start the session, for example a machine that is offline or does not have the agent installed.
Set Your Own Rules
You can specify which machine each project runs on:
ocho pick pin ~/app studio # always run ~/app on studio
ocho pick prefer remote # prefer other machines to this one
ocho pick avoid laptop # use laptop only as a last resort
ocho pick never laptop # never use laptop
To draft rules from your recent sessions, run ocho pick rules suggest.
Links and Previews
Ocho makes it easy to view content hosted by agents on other machines.
Open Links
In a connected session, you can Command-click the link to open it locally. In any terminal, you can also use ocho open:
ocho open http://localhost:3000/settings
ocho open ~/project/out/report.pdf
For a localhost page, you will see it in the browser, as if the server were running on your own computer. A file downloads like any normal browser download.
If multiple sessions are running, add the session name after the command, or pick from the list that appears:
ocho open http://localhost:3000 road-hazards
Keep a Port Open
If you want to keep a port on another machine open while you work, use ocho preview:
ocho preview studio 3000
You will see the matching local port, for example {"port":53012}. Press Control-D to close it.
Files
In Ocho, you can easily transfer files between your own computer and the agent's machine.
Send Files to the Agent
Ocho automatically sends files from your clipboard to other machines when you paste into a connected session.
You can also send files through the command line:
ocho upload studio ./design.png --session SESSION
Download Files
ocho download studio ~/project/out/report.pdf
You can download a file by specifying its path on a remote machine. Downloaded files are saved in ~/Downloads.
Run Programs Built by the Agent
ocho run-file studio ~/project/build/App.app
Ocho can automatically sign and run programs built by agents on your Mac. Other types of files are run on the agent's machine.
Share Markdown Files
You can publish a Markdown file as a web page and share its link:
ocho files share notes/plan.md # a file on your computer
ocho files share-remote studio ~/app/PLAN.md # a file on another machine
Anyone with the link can read the web page. A page you shared by clicking a link in a session updates on its own when the file changes, for a week. To update a page you shared with ocho files share, share the file again. To update all shared files that have changed, run ocho files sync. To take down the web page, run ocho files unshare.
Secrets
In Ocho, it's easy to securely provide aligned agents access to sensitive values without pasting them into the terminal.
Provide a Secret to the Agent
When the agent needs a secret, it can request one by printing a link in the terminal:
ocho://secret/STRIPE_KEY
Open this link, or run: ocho secret STRIPE_KEY
To provide the value to the agent, open the link, or run ocho secret STRIPE_KEY in any terminal. The agent receives the secret as a file on its machine. The secret will not appear in the conversation, the terminal or the command line.
In the demonstration above, the agent requests an API key, you enter the key in ocho secret without it being displayed, and then the agent saves it to .env without printing it.
Secret Expiry
A secret is valid for one hour by default. You can use --ttl to change its expiry to 15m, 8h or never:
ocho secret STRIPE_KEY --ttl 8h
Migrate Sessions
Ocho makes it easy to move sessions between machines. For example, you can move the session SESSION from the machine studio to your local laptop.
ocho migrate studio SESSION laptop --attach
ocho migrate copies the conversation and keeps the same account. If the project folder is already on the target machine, Ocho uses it as it is. Otherwise, Ocho copies the whole folder, without build output and dependencies such as node_modules. To first view which content will be migrated, use --dry-run. If the agent is in the middle of a turn, or is waiting for your approval, you can add --interrupt to force the migration.
Copy a Session
You can also start a new session on any machine from a copy of another session's conversation. The original session remains unchanged.
ocho fork studio SESSION --to laptop --attach
By default, each computer running Ocho keeps a copy of the Claude Code and Codex conversations from your other machines that were active in the last 14 days. This means you can fork a session, even if its original machine is asleep or offline. Unlike ocho migrate, forking a session does not copy the folder's files. To see the copies, run ocho replicas.
Pull Requests
This page is a working draft and has not yet been revised.
Ocho can track the pull requests that your agents create on GitHub, and merge them after all checks pass.
Each pull request is displayed next to the session that created it. Each machine needs to be logged in to GitHub. Run gh auth login on that machine.
ocho pr list
ocho pr status https://github.com/acme/app/pull/42
ocho pr auto-merge https://github.com/acme/app/pull/42
To automatically merge a pull request after its checks pass, run ocho pr auto-merge. To cancel automatic merging, run ocho pr cancel.
Terminal Emulators
You can use Ocho in any terminal, for example Ghostty, iTerm, Terminal, Warp, Kitty, and the terminals built into Zed, VS Code or Cursor.
After you run ocho new, the session opens directly in the current tab.
Even if you close the tab or the connection drops, the agent keeps running. To return to the session, run ocho resume or ocho attach.
Rex
Ocho comes with a plugin that lets it integrate seamlessly into your Rex workflow. You can install it using the following command:
ocho rex install
Keyboard Shortcuts
Ocho's Rex plugin includes the following shortcuts. You can also find these actions under the Ocho category in the Rex command palette.
Press ⌘N to start a session. Press ⌘⇧N to quickly start a session in the home folder.
Press ⌥⌘A to switch between Ocho Attach and a remote terminal.
Press ⌘⇧O to open the links and files in the current tab.
Press ⌘⇧V to send files copied in Finder or copied images to the tab's machine.
Press ⌘⇧T to view a simplified view of an agent's conversation history.
Starting Sessions on Other Rex Hosts
When starting a session on another Rex host, you can choose where the session opens:
Connect on this Mac. The agent runs on the host, and you use it in a tab on the Mac through Ocho Attach.
Open a tab on the host. The agent runs in a tab on the host, and Rex switches to that tab. Before choosing this, turn on Remote Control in Rex's "Settings > Rex Server".
Rex remembers the choice you made for each host. Each host must also be an Ocho machine with the same name.
Letting Agents Start Helper Agents
An agent running in Rex can start helper agents in new tabs (on your Mac or on other hosts) and talk with them:
ocho rex start --machine redwood --agent codex "Run the tests and fix failures"
ocho rex wait ID
ocho rex send ID "Yes, go ahead."
Using Through Slack and iMessage (Alpha)
When you are not at your computer, you can start sessions and reply to agents through Slack or the Messages app.
Connecting Slack
ocho slack connect
Open the link the command prints and sign in with your Slack account. If Ocho has not yet been installed in your workspace, select "Add to Slack". To confirm the connection status, run ocho slack status.
Working in Slack
To begin an Ocho session in Slack, send @Ocho a direct message, or mention @Ocho in a channel. Any replies in the thread of that message are sent to that agent.
Permission requests from the agent (for example, to run a command) are shown in the Slack thread. You can send photos and files in the thread, and files generated by the agent will also be sent here. Ocho in Slack supports most of Ocho's features, including secrets and session titles.
Connecting iMessage
ocho imessage connect --phone +14155552671
Enter the number that you use to send iMessages, including the country code. You will then see a verification code and a number. Use your phone to send the verification code as a text message to that number. After you receive the reply Paired, send !! to select a session, or send !new followed by the task to start a new session. The commands in Slack also apply here.
Working in iMessage
In the Messages app, each conversation is tied to one session at a time. If you send a message while the agent is working, its current turn will stop, and a new turn will begin with your message. The agent's replies are delivered as messages, and the files linked in them are sent as attachments. You can send photos, files and voice memos.
Usage Limitations
Slack and iMessage support Codex, as well as Claude Code version 2.1.289 or later.
You can access a session only when the session's machine is on and connected to the network.
Each file must not exceed 10 MB. The limit for files sent from iMessage is 18 MB. We are working on increasing this limit.
Plugins
With plugins, you can add features to Ocho, or change the way it behaves.
This page covers Ocho plugins, not Claude Code plugins. If you want to share MCP servers or Claude Code plugins, refer to MCP.
How Plugins Work
A plugin is a folder that contains a plugin.toml file:
my-plugin/
plugin.toml # its id, version, scopes, and what to run
main.lua # a Lua script (optional)
service/ # a program in any language (optional)
Plugins run in ocho serve (the background service of Ocho) on each machine. You can write a plugin as a Lua script, or write a program in any language, or combine the two. Scripts respond to events and add commands. A program is called a "service". It runs alongside Ocho and communicates with Ocho over HTTP.
A plugin can only perform the operations that its scopes allow. You grant these scopes when you install the plugin.
Writing Your First Plugin
The following plugin counts the turns your sessions finish, and adds a command for reporting that number. The whole process takes about five minutes.
1. Create the folder. Create hello-plugin/plugin.toml:
id = "hello"
name = "Hello"
version = "0.1.0"
description = "Counts the turns your sessions finish."
api = 1
scopes = ["sessions.read", "ui.command"]
[lua]
main = "main.lua"
sessions.read allows the plugin to receive session events, and ui.command allows the plugin to add commands.
2. Write the script. Create hello-plugin/main.lua:
ocho.on("turn.finished", function(ev)
local n = (ocho.store.get("turns") or 0) + 1
ocho.store.set("turns", n)
end)
ocho.ui.command{name = "count", title = "Hello: turns finished", run = function(ctx)
ocho.log("Sessions here have finished", ocho.store.get("turns") or 0, "turns")
end}
The script runs only once, when the plugin is loaded. After that, each time a turn finishes, Ocho will call your function. ocho.store keeps the count across restarts.
3. Install the plugin.
ocho plugin install ./hello-plugin --link
You will see the scopes that the plugin requests, and you decide whether to grant them. When --link is used, the plugin runs directly from your folder, and the plugin reloads a few seconds after you save a file.
4. Try the plugin. After a session finishes a turn, run this command:
ocho plugin run hello count
tail ~/.local/share/fleet/plugins/hello/plugin.log
You will see the count in the log.
5. Check the plugin status.
ocho plugin list # every plugin, and why one isn't running
ocho plugin status # whether it's running, or what stopped it
Only you can install, update, remove or disable plugins. An agent can write and test a plugin, and then ask you to install it.
Choosing Scopes
A plugin can only obtain the scopes that it requests and that you grant. If a scope lets a plugin act on your behalf, for example sending messages or running commands, you will see a warning before installation.
Scope
With it, the plugin can
sessions.read
View sessions and their states and events
transcript.read
Read session transcripts
sessions.send
Send messages to sessions
sessions.interrupt
Stop the current turn of a session
sessions.label
Rename sessions
sessions.launch
Start sessions
notify
Display notifications
secrets.request
Request secrets from you through secret links
ui.badge, ui.chip, ui.subtitle
Add a badge or a label to a session, or add a line of text under the session title
ui.list, ui.urls, ui.field
Add a list, local servers or a text field in the session sidebar
ui.command
Add commands
net:HOST
Connect to HOST. Use net:*.example.com to also include its subdomains.
exec:COMMAND
Run COMMAND on the machine
To list all scopes, run ocho docs plugins scopes.
Writing Lua Scripts
Scripts never wait, and every call returns immediately. If you need to obtain a result, pass a function as the last argument, and Ocho will call it as done(err, result).
Call
Use it to
ocho.on(event, fn)
have Ocho call fn(ev) for each event
ocho.every(seconds, fn), ocho.after(seconds, fn)
have Ocho run fn repeatedly, or once
ocho.sessions(), ocho.session(s)
get every session, or one
ocho.send(s, text, {now = true})
send a message to a session. Use now = true to stop its current turn first.
When a call takes a session s, you can pass ev.session from an event, a session returned by ocho.sessions(), or {machine = "studio", id = "c68e70ae"}. The following script adds a badge to each session that is waiting for your answer:
ocho.on("approval.needed", function(ev)
ocho.ui.badge(ev.session, {icon = "bell", color = "warn", tooltip = "Needs an answer"})
end)
ocho.on("approval.answered", function(ev)
ocho.ui.badge(ev.session, nil)
end)
Scripts run in Lua 5.4, and the io, os and load libraries are not provided. If a plugin keeps hitting errors, Ocho will disable it until it is reloaded.
Responding to Events
Event
When it is triggered
session.launched, session.ended
A session starts or ends
session.changed
A session's title, state, folder, model or account changes
turn.started, turn.finished
The agent starts or finishes working
message.assistant
The agent's latest reply changes
message.received
A message is delivered to a session
approval.needed, approval.answered
A permission prompt appears or disappears
plugin.ID.NAME
Another plugin called ocho.emit
The ev.session of each event contains the machine, id, title, state, provider, cwd and last_message of the session. Your plugin can receive events for all sessions on all machines.
Writing Services
You can write a service in any language, and specify the program to run in [service]:
[service]
command = ["service/run"]
http = true
The service runs in the plugin folder, and receives the following environment variables:
Variable
Content
OCHO_PLUGIN_URL
The API address of Ocho on this machine
OCHO_PLUGIN_TOKEN
The token of the plugin
OCHO_PLUGIN_DATA
A folder for the plugin to store files
OCHO_PLUGIN_PORT
When http = true is set, the port the service should listen on
After http = true is set, Ocho will forward requests sent to /api/plugins/ID/PATH to your service. The service runs as you: scopes only limit what it can ask Ocho to do, and do not limit what it can do on the machine. When you install a service, you will see a warning about this.
Adding Settings
Plugins can add settings, and these settings appear alongside Ocho's own settings:
[[settings]]
key = "style"
label = "Title style"
type = "string" # string, bool or number
default = "short"
Scripts can read a setting through ocho.settings.get("style"). Do not save secrets or passwords in settings. If a plugin needs this kind of information, it can request it from you through a secret link.
Saving Data
ocho.store saves a plugin's data on each machine. ocho.store.session(s) is used to save the data of a single session:
Saved data is retained across restarts and updates, and each store is limited to 1 MB. After a plugin stops, the badges and commands that it added disappear along with it. If you want something to stay on screen after a restart, save it first, and display it again when the plugin is loaded.
Updating and Removing Plugins
ocho plugin update hello # get the newest files from where you installed it
ocho plugin disable hello # turn it off
ocho plugin enable hello # turn it back on
ocho plugin remove hello # remove it, keeping its saved values
ocho plugin remove hello --purge
When you update a plugin, you only need to grant the scopes the new version adds. You can also install a plugin from a git repository at a specified tag:
The plugins that come with Ocho are built in, and are updated along with Ocho. To disable one of these plugins, run ocho plugin disable ID. To list all plugins, run ocho plugin list.
For the complete reference, run ocho docs plugins.
Troubleshooting Problems
Checking the Settings
To check your local machine, run ocho doctor. To check another machine, add its name:
ocho doctor # your local machine’s tools, versions and sessions
ocho doctor studio # a machine’s hardware, versions and sessions
ocho machine addresses studio --probe # which of a machine’s addresses answer, and how fast
If Ocho is not working properly on a machine, run ocho machine update studio to reinstall it.
Consulting the Built-in Documentation
Ocho has built-in documentation that covers all features. It is primarily designed for agents to quickly learn about and address issues with ocho. To browse or search this documentation, run:
To help track down problems, you can run ocho diagnostics on. After it is turned on, Ocho will record timings, error codes and connection status for up to 72 hours, but it will never record prompts, terminal output, paths or credentials. To view these records, run ocho diagnostics show. To stop recording and delete the existing records, run ocho diagnostics off.
Supported Agents
You can use Ocho with Claude Code and Codex. Most features behave exactly the same on these two agents.
Feature
Claude Code
Codex
Run sessions on any machine
Supported
Supported
Send messages between agents
Supported
Supported
Ocho Attach
2.1.301–2.1.303, 2.1.305–2.1.312 and 2.1.314, only on Macs with Apple silicon
Supported
Slack and iMessage
2.1.289 and later versions
Supported
ocho resume
Supported
Supported
Claude Code Versions and Ocho Attach
Because Ocho Attach patches Claude Code, we support new updates only after they have passed an extensive test suite. If the version installed on the machine is newer than the supported versions, the session will instead use the latest supported version, and you will see the message "Ocho Attach doesn’t support Claude 2.1.320. Running Claude 2.1.314.". Ocho Attach patches its own private copy of Claude Code, and does not modify the Claude Code installed on your machine.
Moving from Tuft to Ocho
This page is for the Tuft agent that moves its user from Tuft to Ocho on the user's office Mac. If you are not that agent, this page is not for you.
You talk to the user only through the current Tuft conversation in iMessage or Slack. The user is not at the Mac, so every step that needs the user is a message you send them, and each step says what to send and what reply to wait for.
How the Move Works
Tuft and Ocho run side by side until the very end. You install Ocho next to Tuft and help the user sign in. Then you start a new Ocho agent, called the helper, and connect Ocho's iMessage or Slack to it. From then on, the user talks to the helper in a new conversation. The helper tests Ocho with the user, offers to bring recent Tuft conversations over, and removes Tuft last.
Follow these rules throughout:
Never stop, uninstall or disconnect Tuft. Only the helper removes Tuft, in its last step, after the user confirms that Ocho works.
Every link or code the user needs reaches them in this Tuft conversation, while Tuft still runs.
Never ask the user to send a password, token or key in a chat. Codes from a sign-in page are the only exception, because they work once and only for the sign-in that is waiting for them.
Tell the user what you are doing before each step, and what comes next.
If a step fails, first try to find the cause and fix it yourself. If you can't, follow Reporting a Problem.
Tuft's shells don't have ~/.local/bin on their PATH, so the Tuft agent's commands below write ~/.local/bin/ocho in full.
1. Ask the User
Send the user a message like this, in your own words:
"Tuft is being replaced by Ocho, which runs the same Claude Code and Codex agents on this Mac. I can set Ocho up now, next to Tuft, so nothing stops working. You'll sign in to Claude or Codex from your phone, then start talking to Ocho in a new conversation. Once you've checked that Ocho works, it removes Tuft. Should I start?"
Wait for the user to agree. If they say no, stop here.
This installs ocho to ~/.local/bin and starts the Ocho service on this Mac. It changes nothing in Tuft. Check that it worked:
~/.local/bin/ocho doctor
Tell the user: "Ocho is installed. Next, you'll sign in to Claude or Codex so that Ocho can use your account."
3. Sign In to Claude or Codex
Ask the user which accounts they use: Claude, Codex or both. The user must sign in to Ocho with the same account they use in Tuft, so that their Tuft conversations can come over. First, find the email of each account Tuft uses. Tuft keeps each session's login in its own folder. Most conversations keep claude-config/ and codex-home/ in the conversation folder itself, and a few keep them in agents/claude/ and agents/codex/. These commands read the newest conversation's login and print only the email:
cfg=$(ls -dt ~/.config/tuft/sessions/*/claude-config ~/.config/tuft/sessions/*/agents/claude/claude-config 2>/dev/null | head -1)
CLAUDE_CONFIG_DIR="$cfg" claude auth status | plutil -extract email raw -o - -
home=$(ls -dt ~/.config/tuft/sessions/*/codex-home ~/.config/tuft/sessions/*/agents/codex/codex-home 2>/dev/null | head -1)
osascript -l JavaScript -e 'function run(a){var d=JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(a[0],4,null)));var p=d.tokens.id_token.split(".")[1].replace(/-/g,"+").replace(/_/g,"/");while(p.length%4)p+="=";return JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding($.NSData.alloc.initWithBase64EncodedStringOptions(p,0),4))).email||""}' "$home/auth.json"
claude auth status prints JSON whose email is the account. codex login status doesn't name the account, so the second command reads the email from the login Tuft saved, without printing the login itself. Each command prints nothing for an agent the user doesn't use in Tuft.
When you send the user a sign-in link below, name the account: "Sign in with EMAIL, the account you use in Tuft." Add each one with ocho accounts add. The sign-in happens on the user's phone, and the command on the Mac waits for it to finish.
Run each command in a tmux session of its own, so that you can read what it prints and type into it. Every Tuft Mac has tmux. Use the -L ocho-login socket every time, so that the session stays out of Tuft's own tmux:
-J joins the lines that a long link wraps over, so copy the link from that output.
Codex prints a link and a short code. Send both to the user: "Open this link on your phone, sign in to your ChatGPT account, and enter this code: CODE." The command finishes on its own once the user signs in. Wait until the pane shows "Connected Codex".
Claude (--provider claude) prints a sign-in link and waits at "Paste code here if prompted". It also tries to open a browser on the Mac. Ignore that, because nobody is at the Mac. Send the link to the user: "Open this link on your phone and sign in to Claude. At the end, Claude shows you a code. Send me that code." When the user sends the code, type it into the waiting command exactly as they sent it:
-l types the code as text, so that tmux never reads part of it as a key name. A code works only for the sign-in that printed its link. If Claude says "That code wasn't accepted", start the command again and send the user the new link.
Wait until the pane shows "Connected Claude". Then close the tmux session with tmux -L ocho-login kill-server.
Run ~/.local/bin/ocho accounts to check that each account is listed, with the same email Tuft uses. If an email differs, tell the user, remove that account with ~/.local/bin/ocho accounts remove ACCOUNT, and repeat the sign-in with the right account. Don't continue with a different account, because conversations from Tuft must continue under the account they were made with. Tell the user which accounts are connected. Write down the account for the helper's agent, such as claude:EMAIL, as ACCOUNT.
4. Start the Helper
Start the helper on this Mac. Use Claude if the user has a Claude account, and Codex otherwise. --account makes the helper run on the account from step 3, which Slack needs: Slack's !attach refuses a session on the Mac's own login.
~/.local/bin/ocho new local --provider claude --account ACCOUNT --cwd ~ --title "Ocho helper" --no-attach "You are the Ocho helper for a user who is moving from Tuft to Ocho. Read https://docs.ocho.app/agents/moving-from-tuft.md and follow the part titled For the Helper. Use the account ACCOUNT for every session you start or resume. Wait for the user's first message in iMessage or Slack before you start. The user's name is NAME."
The last line of the output contains the helper's session ID in "id". Write it down as HELPER. Then wait until the helper has read its instructions and is idle:
Without a terminal, it prints JSON at once and returns: code, recipient (the number to text), expires_in_minutes and a QR. It doesn't wait for the code to arrive. Send the user both values: "From your iPhone, text CODE to RECIPIENT. Ocho replies Paired, and that starts your conversation with Ocho." The code works once, from the number they gave, for 60 minutes. Wait for the user to tell you that Ocho replied. If it doesn't reply, run the command again for a new code.
Slack. Run:
~/.local/bin/ocho slack connect
It prints a link. Send it to the user: "Open this link and add Ocho to your Slack workspace." When the user tells you it's done, check that ~/.local/bin/ocho slack status says connected. Then send them: "In Slack, send a direct message to @Ocho with the text !attach HELPER. That connects you to the Ocho helper."
6. Hand Over to the Helper
Send the user a last message: "Ocho is ready. Send a message to Ocho, in the new iMessage conversation or in your Slack DM with @Ocho, to start testing it. I'll keep running here until Ocho has removed Tuft, so you can come back to me if anything goes wrong."
Your part ends here. Keep answering the user in this conversation if they come back. If Ocho doesn't reply to them, find the cause with ~/.local/bin/ocho doctor and ~/.local/bin/ocho sessions, and repeat step 5 if needed.
For the Helper
You are the Ocho helper. The user talks to you in iMessage or Slack, and Tuft still runs next to you. Tell the user what you are doing before each step. Use ~/.local/bin/ocho for every ocho command, and the account named in your first message for every session you start or resume.
Test Ocho with the User
Introduce yourself, then go through these checks with the user one at a time. Keep the user in this conversation the whole time, and don't ask them to switch sessions until the move is done. Tell the user what each one tests, and wait for them to confirm that it worked:
1. The user sends you a question, and your answer arrives.
2. You start a test session yourself and read its answer:
~/.local/bin/ocho new local --provider claude --account ACCOUNT --cwd ~ --no-attach "Reply with the word hello."
~/.local/bin/ocho sessions wait local/SESSION --until idle
~/.local/bin/ocho sessions last local SESSION
SESSION is the id the first command prints. Tell the user that it replied. This checks that new sessions start, and the user never leaves this conversation.
3. The user sends you a photo, and you describe it.
4. You send the user a file, for example a short text file, and it arrives as an attachment.
5. You request a test secret: print ocho://secret/TEST_VALUE on its own line, run ~/.local/bin/ocho secret ask TEST_VALUE, and ask the user to open the link and enter any word. Confirm that the file arrived, then delete it and tell the user that the test value was deleted.
If a check fails, try to fix it yourself first. If you can't, follow Reporting a Problem.
Offer Recent Tuft Conversations
Ask the user once: "Do you want me to bring any of your recent Tuft conversations into Ocho?" If they say no, skip to the next section.
If they say yes, list their recent Tuft conversations, numbered, each with its title and date, newest first. Start with the last two weeks, and tell the user you can list older ones too. Bring over whichever ones they pick. If they want everything, bring over every conversation. Tuft keeps its data here:
Most conversations keep them in the conversation folder itself. A few keep them in agents/claude/ or agents/codex/ in that folder. Read both, the conversation folder first.
The agent's record
session.json there: spec.backend (the agent), native_session_id, launch_cwd and transcript_path
The title
text in title.json in the same folder
This command lists every agent's record, newest first, from both places:
ls -t ~/.config/tuft/sessions/*/session.json ~/.config/tuft/sessions/*/agents/*/session.json ~/.tuft/sessions/*/session.json 2>/dev/null
Skip any session.json without transcript_path, because it holds no conversation. Bring over each conversation the user picks:
Use the complete native_session_id. Codex IDs created a few minutes apart begin with the same characters, so a shortened ID can pick the wrong conversation. Ocho copies the conversation and continues it in launch_cwd with the account you name. It never moves or changes Tuft's copy. The last line of the output is {"id": …, "machine": …, "state": …}. A conversation with a Tuft title keeps it. If the session has no title, name it with the id from the output:
~/.local/bin/ocho rename local SESSION "TITLE"
A new name can take half a minute to show in ~/.local/bin/ocho sessions --local --fields id,title,state.
If ocho resume says that no login is available, ask the user whether Ocho may use the login that Tuft saved for that conversation. If they agree, add --use-tuft-login. If ocho resume says there is nothing to resume, tell the user, and leave the conversation in Tuft's folder. Don't start it another way, such as ocho new --resume, because that creates a session without the conversation.
Tell the user which conversations came over. Don't switch the user to them yet. Explain that after the move, !sessions lists their sessions and !3 switches to the third one. "Ocho helper" stays in that list, so they can always get back to you.
Remove Tuft
Do this only after every check above passed. Ask the user: "Ocho is working. Should I remove Tuft now? Your Tuft conversations stay on this Mac, but Tuft will stop replying." Wait for the user to agree. Then run:
tuft uninstall
This stops Tuft's services and removes its LaunchAgents. Tuft.app, the tuft command and ~/.config/tuft stay. Don't delete ~/.config/tuft. It leaves the Mac's Tailscale alone, so the Mac stays on Tuft's tailnet. Check that Tuft is gone:
ls ~/Library/LaunchAgents | grep com.expo.tuft
This prints nothing once Tuft is removed. Tell the user: "Tuft is removed. From now on, message Ocho instead of Tuft."
Move the Mac to the User's Own Tailnet
Tuft connected this Mac to Tuft's tailnet. Once Tuft is removed, offer to move the Mac to the user's own Tailscale account, so they can reach it from their other computers. Ocho's iMessage and Slack don't go through Tailscale, so this conversation keeps working while you do it.
Ask the user: "Do you want to reach this Mac from your other computers? I can move it from Tuft's Tailscale network to your own Tailscale account." If they say no, skip this section.
Never run tailscale down or tailscale logout here. Tuft's profile is the way back if the move fails.
First, find the Tailscale command and note the current network, Tuft's, so that you can switch back to it:
mkdir -p ~/ocho-handover && chmod 700 ~/ocho-handover && cd ~/ocho-handover
for t in /Applications/Tailscale.app/Contents/MacOS/Tailscale /opt/homebrew/bin/tailscale /usr/local/bin/tailscale; do
"$t" status --json > status-before.json 2>/dev/null && { echo "$t" > tailscale-cli; break; }
done
"$(cat tailscale-cli)" switch --list --json > profiles-before.json 2>/dev/null
plutil -extract CurrentTailnet.Name raw -o - status-before.json > before-tailnet
osascript -l JavaScript -e 'function run(a){var l=JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(a[0],4,null)));for(var i=0;i<l.length;i++)if(l[i].selected)return l[i].id;return ""}' "$PWD/profiles-before.json" > before-profile
echo office-mac > hostname
cat before-profile before-tailnet
It prints the ID of Tuft's Tailscale profile and the name of Tuft's tailnet. If it prints no profile ID, the Mac has no profile to switch back to, so tell the user that and stop here. hostname is the Mac's name on the user's tailnet. Ask the user if they want another name.
Then ask the user for an auth key: "If you don't have a Tailscale account yet, create one at https://login.tailscale.com/start. Install the Tailscale app on your phone, sign in with that account, and keep it connected. Then open https://login.tailscale.com/admin/settings/keys, choose Generate auth key, leave Reusable and Ephemeral off, turn Pre-approved on if it's offered, and generate the key. Don't send me the key. I'll send you a link to enter it." When they have it, print this on its own line, and run ~/.local/bin/ocho secret ask TS_AUTHKEY:
ocho://secret/TS_AUTHKEY
The user enters the key in the form the link opens. Wait for the file:
until test -s "$OCHO_HOME/secrets/$OCHO_EXECUTION_ID/TS_AUTHKEY"; do sleep 5; done
Save this script as ~/ocho-handover/join-tailnet.sh, exactly as it is:
#!/bin/bash# join-tailnet.sh KEYFILE: move this Mac to the user's own tailnet with the# auth key in KEYFILE, then check it. If any check fails, or this script stops# early, the Mac returns to the network it was on before (Tuft's).# Exit 0 means the Mac is on the user's tailnet and one of their devices answered.
set -u
key=${1:?usage: join-tailnet.sh KEYFILE}
dir=$(cd "$(dirname "$0")" && pwd)
cd "$dir" || exit 2
ts=$(cat tailscale-cli)
before=$(cat before-profile)
before_tailnet=$(cat before-tailnet)
log() { echo "$(date '+%H:%M:%S') $*" | tee -a join-tailnet.log; }
field() { /usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null; }
json() { osascript -l JavaScript -e "function run(a){var d=JSON.parse(ObjC.unwrap(\$.NSString.stringWithContentsOfFileEncodingError(a[0],4,null)));$1}" "$2" 2>/dev/null; }
# running_on = NAME | != NAME: wait up to 60 seconds for Tailscale to run on that tailnet, or on another one.
running_on() {
for _ in $(seq 1 30); do
"$ts" status --json > status.json 2>/dev/null
name=$(field status.json CurrentTailnet.Name)
if [ "$(field status.json BackendState)" = Running ]; then
if { [ "$1" = "=" ] && [ "$name" = "$2" ]; } || { [ "$1" = "!=" ] && [ -n "$name" ] && [ "$name" != "$2" ]; }; then
return 0
fi
fi
sleep 2
done
return 1
}
# The safety net: a launchd job of its own, so nothing that stops this script# stops it. Unless the checks passed, it switches back 4 minutes from now,# then removes itself.
label=ocho-tailnet-safety-net
rm -f passed
launchctl remove "$label" 2>/dev/null
launchctl submit -l "$label" -- /bin/sh -c "sleep 240; [ -f '$dir/passed' ] || { echo \"\$(date '+%H:%M:%S') safety net: switching back to $before_tailnet\" >> '$dir/join-tailnet.log'; '$ts' switch '$before' >> '$dir/join-tailnet.log' 2>&1; }; launchctl remove $label"
disarm() { launchctl remove "$label" 2>/dev/null; }
fail() {
log "FAILED: $*"
log "switching back to $before_tailnet"
"$ts" switch "$before" >> join-tailnet.log 2>&1
if running_on = "$before_tailnet"; then log "back on $before_tailnet"; disarm; else log "NOT back on $before_tailnet: run: $ts switch $before"; fi
exit 1
}
if [ -s user-profile ]; then
# A retry: the one-off key was spent on the first try, and its profile stays.
log "switching to the user's tailnet again"
"$ts" switch "$(cat user-profile)" >> join-tailnet.log 2>&1 || fail "tailscale switch didn't succeed"
else
log "joining the user's tailnet with the auth key"
"$ts" login --auth-key="file:$key" --hostname="$(cat hostname)" --timeout=60s >> join-tailnet.log 2>&1 || fail "tailscale login didn't succeed (see join-tailnet.log)"
fi
running_on != "$before_tailnet" || fail "Tailscale isn't running on another tailnet"
if [ ! -s user-profile ]; then
"$ts" switch --list --json > profiles-after.json 2>/dev/null
json 'for(var i=0;i<d.length;i++)if(d[i].selected)return d[i].id;return ""' "$dir/profiles-after.json" > user-profile
[ -s user-profile ] || fail "can't tell the new profile's ID"
fi
log "running on $(field status.json CurrentTailnet.Name) as $(field status.json Self.DNSName)"
peer=$(json 'var p=d.Peer||{};for(var k in p){if(p[k].Online)return p[k].HostName}return ""' "$dir/status.json")
[ -n "$peer" ] || fail "none of the user's devices is online on the user's tailnet"
"$ts" ping --c 5 --timeout 5s "$peer" >> join-tailnet.log 2>&1 || fail "$peer didn't answer a Tailscale ping"
log "$peer answered"
touch passed
disarm
log "check passed: this Mac is on the user's tailnet"
exit 0
Tell the user: "I'm moving this Mac to your Tailscale network now. It takes about a minute." Then run it:
The script joins the user's tailnet with the key, waits until the Mac is online there, and pings one of the user's own devices. If a check fails, it switches back to Tuft's tailnet. A launchd job switches back within 4 minutes even if the script stops early. The key works once. The log is ~/ocho-handover/join-tailnet.log.
If it printed "check passed", tell the user: "This Mac is on your Tailscale network now, as NAME, and your DEVICE answered it." NAME is the name the log shows after "as". Then ask them: "On another computer of yours with Tailscale, can you see this Mac in the Tailscale app?"
If it failed, the log says which check failed, and the Mac is back on Tuft's tailnet. Tell the user what failed:
"tailscale login didn't succeed": the key was wrong, used or expired. Ask for a new key with the same secret link.
"none of the user's devices is online": ask the user to open Tailscale on their phone and connect it.
"didn't answer a Tailscale ping": ask the user to open Tailscale on that device.
Then run the script again. A retry switches to the profile the first try made, so it needs no new key unless the login itself failed.
Once the user can see the Mac, ask whether to remove Tuft's network from this Mac. If they agree, remove Tuft's profile, which is not the current one:
"$(cat ~/ocho-handover/tailscale-cli)" switch remove "$(cat ~/ocho-handover/before-profile)"
"$(cat ~/ocho-handover/tailscale-cli)" status
The status shows the Mac on the user's network. Until you remove it, tailscale switch with that profile ID returns the Mac to Tuft's tailnet.
Use Ocho from the User's Laptop
Once the Mac is on the user's own tailnet, offer to set up Ocho on the user's laptop, so they can open these sessions there with ocho attach. The laptop joins this Mac's Ocho as a node. A node has its own key and the same authority as this Mac: it sees the same machines, accounts and sessions, and can add or remove machines. Ask the user: "Do you want to use Ocho from your laptop too?" If they say no, skip this section.
The laptop reaches this Mac over SSH, so Remote Login must be on here. Check it:
nc -z localhost 22 && echo on
If it prints nothing, Remote Login is off. Turning it on needs System Settings on this Mac, so tell the user: "When you're next at this Mac, open System Settings, General, Sharing, and turn on Remote Login." Continue with this section after that.
Find this Mac's name on the tailnet and your user name:
"$(cat ~/ocho-handover/tailscale-cli)" status --self
whoami
Then send the user these steps for their laptop, with the names filled in:
It may ask for this Mac's login password once. When it finishes, ocho sessions on your laptop lists the sessions here, and ocho attach opens one."
The join works only while Ocho on the laptop is empty, with no accounts and no other machines. If the user already uses Ocho on that laptop, stop here and tell them. Joining would need their existing setup removed first, so follow Reporting a Problem instead.
When the user says the join finished, check it here:
~/.local/bin/ocho node list
The laptop appears as an active node. Ask the user to run ocho sessions on the laptop and confirm that "Ocho helper" is listed.
What Changes for the User
Share the parts that matter to the user:
In Tuft
In Ocho
Message Tuft in Slack or Messages
Message Ocho. !new TASK starts a session, !sessions lists recent ones, and !3 or !attach SESSION switches to one.
++ to queue, !! to interrupt
A new message interrupts the current turn.
/view for the live terminal
ocho attach SESSION on any of the user's computers
Preview links and *.tuft.host
Links open from the session, or with ocho open
MCP servers and skills in the control panel
The user's own Claude Code and Codex settings
Reporting a Problem
When something fails, the Tuft agent or the helper first tries to find the cause and fix it, with ~/.local/bin/ocho doctor, ~/.local/bin/ocho diagnostics errors, ~/.local/bin/ocho diagnostics show ID, the logs and a retry. Only if that fails, make a report:
1. Write ~/ocho-report/report.md with what you were doing, what you tried, the exact error text and any diagnostic IDs.
2. Save the script below as ~/ocho-report/make-report.sh and run it with bash ~/ocho-report/make-report.sh. It prints the path of the zip.
3. List the zip with unzip -l ZIP, and check that it holds only the files the script names.
4. Send the zip to the user as an attachment, with: "I couldn't fix this myself. Please send this file to Eliot, who develops Ocho and Tuft, on Slack."
The script copies only these files: report.md, the output of ocho doctor and ocho diagnostics errors, and the last 2000 lines of Ocho's serve.log, supervisor-stderr.log, supervisor-updates.log and sessiond/fleet.log, and of Tuft's tuft.log, tuft-tmux-host.log and tuft-cloudflared.log. It never copies a folder. So it leaves out Ocho's secrets, accounts, keys and identity, ~/.claude, ~/.codex, Tuft's claude-config and codex-home, every auth.json and .credentials.json, ~/.ssh and every conversation transcript. It also drops every line that could hold a credential, notes in removed.txt how many it dropped, and checks the finished zip again:
#!/bin/bash# make-report.sh: zip a problem report as ~/ocho-report-DATE.zip, from# ~/ocho-report/report.md (write it first), the output of ocho doctor and# ocho diagnostics errors, and the last 2000 lines of Ocho's and Tuft's logs.# It copies only those files, never a folder, and drops every line that could# hold a credential. Then it checks the zip. Prints the zip's path.
set -u
r="$HOME/ocho-report"
o="${OCHO_HOME:-$HOME/.local/share/fleet}"
t="$HOME/.config/tuft/logs"
ocho="$(command -v ocho || echo "$HOME/.local/bin/ocho")"
cd "$r" 2>/dev/null && [ -s report.md ] || { echo "Write $r/report.md first." >&2; exit 1; }
rm -rf logs removed.txt && mkdir logs
"$ocho" doctor > doctor.txt 2>&1
"$ocho" diagnostics errors > diagnostics-errors.txt 2>&1
for f in serve.log supervisor-stderr.log supervisor-updates.log sessiond/fleet.log; do
[ -f "$o/$f" ] && tail -n 2000 "$o/$f" > "logs/ocho-$(basename "$f")"
done
for f in tuft.log tuft-tmux-host.log tuft-cloudflared.log; do
[ -f "$t/$f" ] && tail -n 2000 "$t/$f" > "logs/$f"
done
pat='sk-ant-|tskey-|xox[abpr]-|gh[pousr]_[A-Za-z0-9]{20}|eyJ[A-Za-z0-9_-]{20,}|BEGIN [A-Z ]*PRIVATE KEY|access_token|refresh_token|id_token|api[_-]?key|bearer |password|secret|cookie|authorization'
touch removed.txt
for f in report.md doctor.txt diagnostics-errors.txt logs/*; do
[ -f "$f" ] || continue
n=$(grep -c -E -i "$pat" "$f")
if [ "$n" -gt 0 ]; then
grep -v -E -i "$pat" "$f" > "$f.tmp"; mv "$f.tmp" "$f"
echo "$f: removed $n lines that could hold a credential" >> removed.txt
fi
done
zip="$HOME/ocho-report-$(date +%Y%m%d-%H%M).zip"
rm -f "$zip"
zip -qr "$zip" report.md doctor.txt diagnostics-errors.txt removed.txt logs
if unzip -p "$zip" | grep -q -E -i "$pat"; then
rm -f "$zip"; echo "The report still matched a credential pattern; nothing was zipped." >&2; exit 1
fi
echo "$zip"